diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index e8177de1a..e3365f6f6 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -184,6 +184,7 @@ DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB00CA962632DDB600A54956 /* CommonOSLog */; }; DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; }; DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; + DB023295267F0AB800031745 /* ASMetaEditableTextNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB023294267F0AB800031745 /* ASMetaEditableTextNode.swift */; }; DB029E95266A20430062874E /* MastodonAuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB029E94266A20430062874E /* MastodonAuthenticationController.swift */; }; DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; }; DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; }; @@ -203,6 +204,9 @@ DB1D84382657B275000346B3 /* SegmentedControlNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D84372657B275000346B3 /* SegmentedControlNavigateable.swift */; }; DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */; }; DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E347725F519300079D7DF /* PickServerItem.swift */; }; + DB1EE7AE267F3071000CC337 /* MastodonStatusContent+ParseResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1EE7AD267F3071000CC337 /* MastodonStatusContent+ParseResult.swift */; }; + DB1EE7B0267F3088000CC337 /* MastodonStatusContent+Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1EE7AF267F3088000CC337 /* MastodonStatusContent+Appearance.swift */; }; + DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1EE7B1267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift */; }; DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */; }; DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */; }; DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */; }; @@ -408,6 +412,16 @@ DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */; }; DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */; }; DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; + DBAC6483267D0B21007FE9FD /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = DBAC6482267D0B21007FE9FD /* DifferenceKit */; }; + DBAC6485267D0F9E007FE9FD /* StatusNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6484267D0F9E007FE9FD /* StatusNode.swift */; }; + DBAC6488267D388B007FE9FD /* ASTableNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6487267D388B007FE9FD /* ASTableNode.swift */; }; + DBAC648A267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6489267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift */; }; + DBAC648F267DC84D007FE9FD /* TableNodeDiffableDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC648E267DC84D007FE9FD /* TableNodeDiffableDataSource.swift */; }; + DBAC6497267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */; }; + DBAC6499267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */; }; + DBAC649B267DF8C8007FE9FD /* ActivityIndicatorNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC649A267DF8C8007FE9FD /* ActivityIndicatorNode.swift */; }; + DBAC649E267DFE43007FE9FD /* DiffableDataSources in Frameworks */ = {isa = PBXBuildFile; productRef = DBAC649D267DFE43007FE9FD /* DiffableDataSources */; }; + DBAC64A1267E6D02007FE9FD /* Fuzi in Frameworks */ = {isa = PBXBuildFile; productRef = DBAC64A0267E6D02007FE9FD /* Fuzi */; }; DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; }; DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; }; DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */; }; @@ -443,6 +457,14 @@ DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; }; DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */; }; DBCBCBF4267CB070000F5B51 /* Decode85.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCBF3267CB070000F5B51 /* Decode85.swift */; }; + DBCBCBFC2680ADB7000F5B51 /* AsyncHomeTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCBFB2680ADB7000F5B51 /* AsyncHomeTimelineViewController.swift */; }; + DBCBCBFF2680AE98000F5B51 /* AsyncHomeTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCBFE2680AE98000F5B51 /* AsyncHomeTimelineViewModel.swift */; }; + DBCBCC012680AF2A000F5B51 /* AsyncHomeTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCC002680AF2A000F5B51 /* AsyncHomeTimelineViewModel+Diffable.swift */; }; + DBCBCC032680AF6E000F5B51 /* AsyncHomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCC022680AF6E000F5B51 /* AsyncHomeTimelineViewController+DebugAction.swift */; }; + DBCBCC052680AFB9000F5B51 /* AsyncHomeTimelineViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCC042680AFB9000F5B51 /* AsyncHomeTimelineViewController+Provider.swift */; }; + DBCBCC072680AFEC000F5B51 /* AsyncHomeTimelineViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCC062680AFEC000F5B51 /* AsyncHomeTimelineViewModel+LoadLatestState.swift */; }; + DBCBCC092680B01B000F5B51 /* AsyncHomeTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCC082680B01B000F5B51 /* AsyncHomeTimelineViewModel+LoadMiddleState.swift */; }; + DBCBCC0B2680B03F000F5B51 /* AsyncHomeTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBCC0A2680B03F000F5B51 /* AsyncHomeTimelineViewModel+LoadOldestState.swift */; }; DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */; }; DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */; }; DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B2F261440A50045B23D /* UITabBarController.swift */; }; @@ -758,6 +780,7 @@ CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.debug.xcconfig"; sourceTree = ""; }; DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; + DB023294267F0AB800031745 /* ASMetaEditableTextNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASMetaEditableTextNode.swift; sourceTree = ""; }; DB029E94266A20430062874E /* MastodonAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationController.swift; sourceTree = ""; }; DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = ""; }; DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = ""; }; @@ -779,6 +802,9 @@ DB1D84372657B275000346B3 /* SegmentedControlNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedControlNavigateable.swift; sourceTree = ""; }; DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = ""; }; DB1E347725F519300079D7DF /* PickServerItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PickServerItem.swift; sourceTree = ""; }; + DB1EE7AD267F3071000CC337 /* MastodonStatusContent+ParseResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonStatusContent+ParseResult.swift"; sourceTree = ""; }; + DB1EE7AF267F3088000CC337 /* MastodonStatusContent+Appearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonStatusContent+Appearance.swift"; sourceTree = ""; }; + DB1EE7B1267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusNodeDelegate.swift"; sourceTree = ""; }; DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+LoadIndexedServerState.swift"; sourceTree = ""; }; DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSection.swift; sourceTree = ""; }; DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+Diffable.swift"; sourceTree = ""; }; @@ -983,6 +1009,13 @@ DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldCollectionViewCell.swift; sourceTree = ""; }; DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Field.swift"; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; + DBAC6484267D0F9E007FE9FD /* StatusNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusNode.swift; sourceTree = ""; }; + DBAC6487267D388B007FE9FD /* ASTableNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASTableNode.swift; sourceTree = ""; }; + DBAC6489267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSDiffableDataSourceSnapshot.swift; sourceTree = ""; }; + DBAC648E267DC84D007FE9FD /* TableNodeDiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableNodeDiffableDataSource.swift; sourceTree = ""; }; + DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderNode.swift; sourceTree = ""; }; + DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderNode.swift; sourceTree = ""; }; + DBAC649A267DF8C8007FE9FD /* ActivityIndicatorNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorNode.swift; sourceTree = ""; }; DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = ""; }; DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = ""; }; DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProviderFacade.swift; sourceTree = ""; }; @@ -1016,6 +1049,14 @@ DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = ""; }; DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPublishService.swift; sourceTree = ""; }; DBCBCBF3267CB070000F5B51 /* Decode85.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Decode85.swift; sourceTree = ""; }; + DBCBCBFB2680ADB7000F5B51 /* AsyncHomeTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncHomeTimelineViewController.swift; sourceTree = ""; }; + DBCBCBFE2680AE98000F5B51 /* AsyncHomeTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncHomeTimelineViewModel.swift; sourceTree = ""; }; + DBCBCC002680AF2A000F5B51 /* AsyncHomeTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncHomeTimelineViewModel+Diffable.swift"; sourceTree = ""; }; + DBCBCC022680AF6E000F5B51 /* AsyncHomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncHomeTimelineViewController+DebugAction.swift"; sourceTree = ""; }; + DBCBCC042680AFB9000F5B51 /* AsyncHomeTimelineViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncHomeTimelineViewController+Provider.swift"; sourceTree = ""; }; + DBCBCC062680AFEC000F5B51 /* AsyncHomeTimelineViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncHomeTimelineViewModel+LoadLatestState.swift"; sourceTree = ""; }; + DBCBCC082680B01B000F5B51 /* AsyncHomeTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncHomeTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; + DBCBCC0A2680B03F000F5B51 /* AsyncHomeTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncHomeTimelineViewModel+LoadOldestState.swift"; sourceTree = ""; }; DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+Diffable.swift"; sourceTree = ""; }; DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFetchedResultsController.swift; sourceTree = ""; }; DBCC3B2F261440A50045B23D /* UITabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITabBarController.swift; sourceTree = ""; }; @@ -1067,8 +1108,11 @@ DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */, DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */, + DBAC6483267D0B21007FE9FD /* DifferenceKit in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, + DBAC64A1267E6D02007FE9FD /* Fuzi in Frameworks */, DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, + DBAC649E267DFE43007FE9FD /* DiffableDataSources in Frameworks */, 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */, DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */, @@ -1265,6 +1309,7 @@ isa = PBXGroup; children = ( DB1F239626117C360057430E /* View */, + DBCBCBFD2680ADBA000F5B51 /* AsyncHomeTimeline */, 2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */, 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+Provider.swift */, 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */, @@ -1283,6 +1328,7 @@ 2D38F1FD25CD481700561493 /* StatusProvider.swift */, 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */, 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */, + DB1EE7B1267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift */, DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */, DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */, DB1D843526579DB5000346B3 /* StatusProvider+TableViewControllerNavigateable.swift */, @@ -1339,6 +1385,7 @@ DB51D171262832380062B7A1 /* BlurHashEncode.swift */, DB6180EC26391C6C0018D199 /* TransitioningMath.swift */, DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */, + DBAC649A267DF8C8007FE9FD /* ActivityIndicatorNode.swift */, ); path = Vender; sourceTree = ""; @@ -1416,6 +1463,7 @@ 2D76319D25C151F600929FB9 /* Section */, 2D7631B125C159E700929FB9 /* Item */, DBCBED2226132E1D00B49291 /* FetchedResultsController */, + DBAC6490267DC84F007FE9FD /* DataSource */, ); path = Diffiable; sourceTree = ""; @@ -1466,6 +1514,7 @@ DB87D45C2609DE6600D12C0D /* TextField */, DB1D187125EF5BBD003F1F23 /* TableView */, 2D7631A625C1533800929FB9 /* TableviewCell */, + DBAC6486267D0FAC007FE9FD /* Node */, ); path = View; sourceTree = ""; @@ -1635,6 +1684,16 @@ path = Onboarding; sourceTree = ""; }; + DB023296267F0ABE00031745 /* Status */ = { + isa = PBXGroup; + children = ( + DBAC6484267D0F9E007FE9FD /* StatusNode.swift */, + DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */, + DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */, + ); + path = Status; + sourceTree = ""; + }; DB084B5125CBC56300F898ED /* CoreDataStack */ = { isa = PBXGroup; children = ( @@ -2170,6 +2229,7 @@ DBCC3B35261440BA0045B23D /* UINavigationController.swift */, DB6D1B23263684C600ACB481 /* UserDefaults.swift */, DB97131E2666078B00BD1E90 /* Date.swift */, + DBAC6489267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift */, ); path = Extension; sourceTree = ""; @@ -2288,6 +2348,8 @@ isa = PBXGroup; children = ( 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */, + DB1EE7AD267F3071000CC337 /* MastodonStatusContent+ParseResult.swift */, + DB1EE7AF267F3088000CC337 /* MastodonStatusContent+Appearance.swift */, DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */, DB35FC2E26130172006193C9 /* MastodonField.swift */, DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */, @@ -2331,6 +2393,24 @@ path = View; sourceTree = ""; }; + DBAC6486267D0FAC007FE9FD /* Node */ = { + isa = PBXGroup; + children = ( + DB023296267F0ABE00031745 /* Status */, + DB023294267F0AB800031745 /* ASMetaEditableTextNode.swift */, + ); + path = Node; + sourceTree = ""; + }; + DBAC6490267DC84F007FE9FD /* DataSource */ = { + isa = PBXGroup; + children = ( + DBAC6487267D388B007FE9FD /* ASTableNode.swift */, + DBAC648E267DC84D007FE9FD /* TableNodeDiffableDataSource.swift */, + ); + path = DataSource; + sourceTree = ""; + }; DBAE3F742615DD63004B8251 /* UserProvider */ = { isa = PBXGroup; children = ( @@ -2412,6 +2492,21 @@ path = Cell; sourceTree = ""; }; + DBCBCBFD2680ADBA000F5B51 /* AsyncHomeTimeline */ = { + isa = PBXGroup; + children = ( + DBCBCBFB2680ADB7000F5B51 /* AsyncHomeTimelineViewController.swift */, + DBCBCC022680AF6E000F5B51 /* AsyncHomeTimelineViewController+DebugAction.swift */, + DBCBCC042680AFB9000F5B51 /* AsyncHomeTimelineViewController+Provider.swift */, + DBCBCBFE2680AE98000F5B51 /* AsyncHomeTimelineViewModel.swift */, + DBCBCC002680AF2A000F5B51 /* AsyncHomeTimelineViewModel+Diffable.swift */, + DBCBCC062680AFEC000F5B51 /* AsyncHomeTimelineViewModel+LoadLatestState.swift */, + DBCBCC082680B01B000F5B51 /* AsyncHomeTimelineViewModel+LoadMiddleState.swift */, + DBCBCC0A2680B03F000F5B51 /* AsyncHomeTimelineViewModel+LoadOldestState.swift */, + ); + path = AsyncHomeTimeline; + sourceTree = ""; + }; DBCBED2226132E1D00B49291 /* FetchedResultsController */ = { isa = PBXGroup; children = ( @@ -2513,6 +2608,9 @@ DBB525072611EAC0002F1F29 /* Tabman */, DB6F5E31264E7410009108F4 /* TwitterTextEditor */, DBAEDE5E267A0B1500D25FF5 /* Nuke */, + DBAC6482267D0B21007FE9FD /* DifferenceKit */, + DBAC649D267DFE43007FE9FD /* DiffableDataSources */, + DBAC64A0267E6D02007FE9FD /* Fuzi */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -2701,6 +2799,9 @@ DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */, DB6F5E30264E7410009108F4 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, DBAEDE5D267A0B1500D25FF5 /* XCRemoteSwiftPackageReference "Nuke" */, + DBAC6481267D0B21007FE9FD /* XCRemoteSwiftPackageReference "DifferenceKit" */, + DBAC649C267DFE43007FE9FD /* XCRemoteSwiftPackageReference "DiffableDataSources" */, + DBAC649F267E6D01007FE9FD /* XCRemoteSwiftPackageReference "Fuzi" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -2957,6 +3058,7 @@ 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, 2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */, 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, + DBCBCBFC2680ADB7000F5B51 /* AsyncHomeTimelineViewController.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */, DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */, @@ -2996,9 +3098,11 @@ DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */, DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */, + DBAC648F267DC84D007FE9FD /* TableNodeDiffableDataSource.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */, DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, + DBAC649B267DF8C8007FE9FD /* ActivityIndicatorNode.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */, @@ -3040,6 +3144,7 @@ DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, + DBAC648A267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift in Sources */, DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */, DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, @@ -3058,9 +3163,12 @@ DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, 2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */, DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */, + DBCBCBFF2680AE98000F5B51 /* AsyncHomeTimelineViewModel.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */, + DBAC6497267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift in Sources */, DB97131F2666078B00BD1E90 /* Date.swift in Sources */, + DBAC6485267D0F9E007FE9FD /* StatusNode.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */, DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */, DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */, @@ -3108,6 +3216,7 @@ 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */, DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, + DBCBCC012680AF2A000F5B51 /* AsyncHomeTimelineViewModel+Diffable.swift in Sources */, DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */, DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */, 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */, @@ -3133,6 +3242,7 @@ 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */, + DBAC6488267D388B007FE9FD /* ASTableNode.swift in Sources */, DB6D9F76263587C7008423CD /* SettingFetchedResultController.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, 5D0393902612D259007FE196 /* WebViewController.swift in Sources */, @@ -3163,6 +3273,7 @@ DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */, DB6F5E2F264E5518009108F4 /* MastodonRegex.swift in Sources */, + DB023295267F0AB800031745 /* ASMetaEditableTextNode.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */, @@ -3179,6 +3290,7 @@ DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */, DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, + DBCBCC092680B01B000F5B51 /* AsyncHomeTimelineViewModel+LoadMiddleState.swift in Sources */, 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */, DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, @@ -3191,6 +3303,7 @@ 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */, 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, + DBCBCC052680AFB9000F5B51 /* AsyncHomeTimelineViewController+Provider.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */, @@ -3222,6 +3335,7 @@ DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */, 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */, DB084B5725CBC56C00F898ED /* Status.swift in Sources */, + DBCBCC072680AFEC000F5B51 /* AsyncHomeTimelineViewModel+LoadLatestState.swift in Sources */, 2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */, DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */, DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, @@ -3236,10 +3350,12 @@ DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, 2D7867192625B77500211898 /* NotificationItem.swift in Sources */, + DB1EE7AE267F3071000CC337 /* MastodonStatusContent+ParseResult.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, + DBCBCC032680AF6E000F5B51 /* AsyncHomeTimelineViewController+DebugAction.swift in Sources */, DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */, @@ -3252,6 +3368,7 @@ DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */, + DB1EE7B0267F3088000CC337 /* MastodonStatusContent+Appearance.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+Provider.swift in Sources */, 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */, 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, @@ -3266,6 +3383,7 @@ 5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */, DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, + DBAC6499267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, DBBF1DBF2652401B00E5B703 /* AutoCompleteViewModel.swift in Sources */, DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */, @@ -3290,6 +3408,7 @@ DB9A489026035963008B817C /* APIService+Media.swift in Sources */, 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */, DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */, + DBCBCC0B2680B03F000F5B51 /* AsyncHomeTimelineViewModel+LoadOldestState.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, @@ -3305,6 +3424,7 @@ DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, + DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */, 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */, 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */, @@ -4122,6 +4242,30 @@ minimumVersion = 1.4.1; }; }; + DBAC6481267D0B21007FE9FD /* XCRemoteSwiftPackageReference "DifferenceKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ra1028/DifferenceKit.git"; + requirement = { + kind = exactVersion; + version = 1.2.0; + }; + }; + DBAC649C267DFE43007FE9FD /* XCRemoteSwiftPackageReference "DiffableDataSources" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MainasuK/DiffableDataSources.git"; + requirement = { + branch = "feature/async-display-table"; + kind = branch; + }; + }; + DBAC649F267E6D01007FE9FD /* XCRemoteSwiftPackageReference "Fuzi" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/cezheng/Fuzi.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.1.3; + }; + }; DBAEDE5D267A0B1500D25FF5 /* XCRemoteSwiftPackageReference "Nuke" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kean/Nuke.git"; @@ -4205,6 +4349,21 @@ package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; productName = "UITextView+Placeholder"; }; + DBAC6482267D0B21007FE9FD /* DifferenceKit */ = { + isa = XCSwiftPackageProductDependency; + package = DBAC6481267D0B21007FE9FD /* XCRemoteSwiftPackageReference "DifferenceKit" */; + productName = DifferenceKit; + }; + DBAC649D267DFE43007FE9FD /* DiffableDataSources */ = { + isa = XCSwiftPackageProductDependency; + package = DBAC649C267DFE43007FE9FD /* XCRemoteSwiftPackageReference "DiffableDataSources" */; + productName = DiffableDataSources; + }; + DBAC64A0267E6D02007FE9FD /* Fuzi */ = { + isa = XCSwiftPackageProductDependency; + package = DBAC649F267E6D01007FE9FD /* XCRemoteSwiftPackageReference "Fuzi" */; + productName = Fuzi; + }; DBAEDE5E267A0B1500D25FF5 /* Nuke */ = { isa = XCSwiftPackageProductDependency; package = DBAEDE5D267A0B1500D25FF5 /* XCRemoteSwiftPackageReference "Nuke" */; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 73b68ec90..5de48646b 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 16 + 26 Mastodon - RTL.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 17 + 27 SuppressBuildableAutocreation diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index a64f6ed7d..44a63faa4 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -46,6 +46,33 @@ "version": "0.1.1" } }, + { + "package": "DiffableDataSources", + "repositoryURL": "https://github.com/MainasuK/DiffableDataSources.git", + "state": { + "branch": "feature/async-display-table", + "revision": "73393a97690959d24387c95594c045c62d9c47cf", + "version": null + } + }, + { + "package": "DifferenceKit", + "repositoryURL": "https://github.com/ra1028/DifferenceKit.git", + "state": { + "branch": null, + "revision": "62745d7780deef4a023a792a1f8f763ec7bf9705", + "version": "1.2.0" + } + }, + { + "package": "Fuzi", + "repositoryURL": "https://github.com/cezheng/Fuzi.git", + "state": { + "branch": null, + "revision": "f08c8323da21e985f3772610753bcfc652c2103f", + "version": "3.1.3" + } + }, { "package": "KeychainAccess", "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git", @@ -60,7 +87,7 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e", + "revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8", "version": "6.2.1" } }, diff --git a/Mastodon/Diffiable/DataSource/ASTableNode.swift b/Mastodon/Diffiable/DataSource/ASTableNode.swift new file mode 100644 index 000000000..f2849cfe8 --- /dev/null +++ b/Mastodon/Diffiable/DataSource/ASTableNode.swift @@ -0,0 +1,81 @@ +// +// ASTableNode.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-6-19. +// + +import UIKit +import AsyncDisplayKit +import DifferenceKit +import DiffableDataSources + +extension ASTableNode: ReloadableTableView { + public func reload( + using stagedChangeset: StagedChangeset, + deleteSectionsAnimation: @autoclosure () -> UITableView.RowAnimation, + insertSectionsAnimation: @autoclosure () -> UITableView.RowAnimation, + reloadSectionsAnimation: @autoclosure () -> UITableView.RowAnimation, + deleteRowsAnimation: @autoclosure () -> UITableView.RowAnimation, + insertRowsAnimation: @autoclosure () -> UITableView.RowAnimation, + reloadRowsAnimation: @autoclosure () -> UITableView.RowAnimation, + interrupt: ((Changeset) -> Bool)? = nil, + setData: (C) -> Void + ) { + if case .none = view.window, let data = stagedChangeset.last?.data { + setData(data) + return reloadData() + } + + for changeset in stagedChangeset { + if let interrupt = interrupt, interrupt(changeset), let data = stagedChangeset.last?.data { + setData(data) + return reloadData() + } + + func updates() { + setData(changeset.data) + + if !changeset.sectionDeleted.isEmpty { + deleteSections(IndexSet(changeset.sectionDeleted), with: deleteSectionsAnimation()) + } + + if !changeset.sectionInserted.isEmpty { + insertSections(IndexSet(changeset.sectionInserted), with: insertSectionsAnimation()) + } + + if !changeset.sectionUpdated.isEmpty { + reloadSections(IndexSet(changeset.sectionUpdated), with: reloadSectionsAnimation()) + } + + for (source, target) in changeset.sectionMoved { + moveSection(source, toSection: target) + } + + if !changeset.elementDeleted.isEmpty { + deleteRows(at: changeset.elementDeleted.map { IndexPath(row: $0.element, section: $0.section) }, with: deleteRowsAnimation()) + } + + if !changeset.elementInserted.isEmpty { + insertRows(at: changeset.elementInserted.map { IndexPath(row: $0.element, section: $0.section) }, with: insertRowsAnimation()) + } + + if !changeset.elementUpdated.isEmpty { + reloadRows(at: changeset.elementUpdated.map { IndexPath(row: $0.element, section: $0.section) }, with: reloadRowsAnimation()) + } + + for (source, target) in changeset.elementMoved { + moveRow(at: IndexPath(row: source.element, section: source.section), to: IndexPath(row: target.element, section: target.section)) + } + } + + if isNodeLoaded { + view.beginUpdates() + updates() + view.endUpdates(animated: false, completion: nil) + } else { + updates() + } + } + } +} diff --git a/Mastodon/Diffiable/DataSource/TableNodeDiffableDataSource.swift b/Mastodon/Diffiable/DataSource/TableNodeDiffableDataSource.swift new file mode 100644 index 000000000..508f07de9 --- /dev/null +++ b/Mastodon/Diffiable/DataSource/TableNodeDiffableDataSource.swift @@ -0,0 +1,111 @@ +// +// TableNodeDiffableDataSource.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-6-19. +// + +import UIKit +import AsyncDisplayKit +import DiffableDataSources + +open class TableNodeDiffableDataSource: NSObject, ASTableDataSource { + /// The type of closure providing the cell. + public typealias CellProvider = (ASTableNode, IndexPath, ItemIdentifierType) -> ASCellNodeBlock? + + /// The default animation to updating the views. + public var defaultRowAnimation: UITableView.RowAnimation = .automatic + + private weak var tableNode: ASTableNode? + private let cellProvider: CellProvider + private let core = DiffableDataSourceCore() + + /// Creates a new data source. + /// + /// - Parameters: + /// - tableView: A table view instance to be managed. + /// - cellProvider: A closure to dequeue the cell for rows. + public init(tableNode: ASTableNode, cellProvider: @escaping CellProvider) { + self.tableNode = tableNode + self.cellProvider = cellProvider + super.init() + + tableNode.dataSource = self + } + + /// Applies given snapshot to perform automatic diffing update. + /// + /// - Parameters: + /// - snapshot: A snapshot object to be applied to data model. + /// - animatingDifferences: A Boolean value indicating whether to update with + /// diffing animation. + /// - completion: An optional completion block which is called when the complete + /// performing updates. + public func apply(_ snapshot: DiffableDataSourceSnapshot, animatingDifferences: Bool = true, completion: (() -> Void)? = nil) { + core.apply(snapshot, view: tableNode, animatingDifferences: animatingDifferences, completion: completion) + } + + /// Returns a new snapshot object of current state. + /// + /// - Returns: A new snapshot object of current state. + public func snapshot() -> DiffableDataSourceSnapshot { + return core.snapshot() + } + + /// Returns an item identifier for given index path. + /// + /// - Parameters: + /// - indexPath: An index path for the item identifier. + /// + /// - Returns: An item identifier for given index path. + public func itemIdentifier(for indexPath: IndexPath) -> ItemIdentifierType? { + return core.itemIdentifier(for: indexPath) + } + + /// Returns an index path for given item identifier. + /// + /// - Parameters: + /// - itemIdentifier: An identifier of item. + /// + /// - Returns: An index path for given item identifier. + public func indexPath(for itemIdentifier: ItemIdentifierType) -> IndexPath? { + return core.indexPath(for: itemIdentifier) + } + + /// Returns the number of sections in the data source. + /// + /// - Parameters: + /// - tableNode: A table node instance managed by `self`. + /// + /// - Returns: The number of sections in the data source. + public func numberOfSections(in tableNode: ASTableNode) -> Int { + return core.numberOfSections() + } + + /// Returns the number of items in the specified section. + /// + /// - Parameters: + /// - tableNode: A table node instance managed by `self`. + /// - section: An index of section. + /// + /// - Returns: The number of items in the specified section. + public func tableNode(_ tableNode: ASTableNode, numberOfRowsInSection section: Int) -> Int { + return core.numberOfItems(inSection: section) + } + + /// Returns a cell for row at specified index path. + /// + /// - Parameters: + /// - tableView: A table view instance managed by `self`. + /// - indexPath: An index path for cell. + /// + /// - Returns: A cell for row at specified index path. + open func tableNode(_ tableNode: ASTableNode, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock { + let itemIdentifier = core.unsafeItemIdentifier(for: indexPath) + guard let block = cellProvider(tableNode, indexPath, itemIdentifier) else { + fatalError("UITableView dataSource returned a nil cell for row at index path: \(indexPath), tableNode: \(tableNode), itemIdentifier: \(itemIdentifier)") + } + + return block + } +} diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index 04a1262d5..fe40cfd6c 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -10,6 +10,7 @@ import CoreData import CoreDataStack import Foundation import MastodonSDK +import DifferenceKit /// Note: update Equatable when change case enum Item { @@ -158,3 +159,5 @@ extension Item: Hashable { } } } + +extension Item: Differentiable { } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 8a3df09b1..d0cfe43fe 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -11,6 +11,7 @@ import CoreDataStack import os.log import UIKit import AVKit +import AsyncDisplayKit import Nuke protocol StatusCell: DisposeBagCollectable { @@ -23,6 +24,33 @@ enum StatusSection: Equatable, Hashable { } extension StatusSection { + static func tableNodeDiffableDataSource( + tableNode: ASTableNode, + managedObjectContext: NSManagedObjectContext + ) -> TableNodeDiffableDataSource { + TableNodeDiffableDataSource(tableNode: tableNode) { tableNode, indexPath, item in + switch item { + case .homeTimelineIndex(let objectID, let attribute): + guard let homeTimelineIndex = try? managedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { + return { ASCellNode() } + } + let status = homeTimelineIndex.status + + return { () -> ASCellNode in + let cellNode = StatusNode(status: status) + return cellNode + } + case .homeMiddleLoader: + return { TimelineMiddleLoaderNode() } + case .bottomLoader: + return { TimelineBottomLoaderNode() } + default: + return { ASCellNode() } + } + } + } + + static func tableViewDiffableDataSource( for tableView: UITableView, dependency: NeedsDependency, diff --git a/Mastodon/Extension/NSDiffableDataSourceSnapshot.swift b/Mastodon/Extension/NSDiffableDataSourceSnapshot.swift new file mode 100644 index 000000000..c2ff341d9 --- /dev/null +++ b/Mastodon/Extension/NSDiffableDataSourceSnapshot.swift @@ -0,0 +1,24 @@ +// +// NSDiffableDataSourceSnapshot.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-6-19. +// + +import UIKit + +//extension NSDiffableDataSourceSnapshot { +// func itemIdentifier(for indexPath: IndexPath) -> ItemIdentifierType? { +// guard 0.. Bool { + return lhs.document == rhs.document + && lhs.original == rhs.original + && lhs.trimmed == rhs.trimmed + && lhs.activeEntities.count == rhs.activeEntities.count // FIXME: + } + + func hash(into hasher: inout Hasher) { + hasher.combine(document) + hasher.combine(original) + hasher.combine(trimmed) + hasher.combine(activeEntities.count) // FIXME: + } + + func trimmedAttributedString(appearance: MastodonStatusContent.Appearance) -> NSAttributedString { + let attributedString = NSMutableAttributedString(string: trimmed, attributes: appearance.attributes) + for entity in activeEntities { + switch entity.type { + case .url: + attributedString.addAttributes(appearance.urlAttributes, range: entity.range) + case .hashtag: + attributedString.addAttributes(appearance.hashtagAttributes, range: entity.range) + case .mention: + attributedString.addAttributes(appearance.mentionAttributes, range: entity.range) + default: + break + } + if let uri = entity.type.uri { + attributedString.addAttributes([ + .link: uri + ], range: entity.range) + } + } + return attributedString + } + } +} + +extension ActiveEntityType { + + static let appScheme = "mastodon" + + init?(url: URL) { + guard let scheme = url.scheme?.lowercased() else { return nil } + guard scheme == ActiveEntityType.appScheme else { + self = .url("", trimmed: "", url: url.absoluteString, userInfo: nil) + return + } + + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let parameters = components.queryItems else { return nil } + + if let hashtag = parameters.first(where: { $0.name == "hashtag" }), let encoded = hashtag.value, let value = String(base64Encoded: encoded) { + self = .hashtag(value, userInfo: nil) + return + } + if let mention = parameters.first(where: { $0.name == "mention" }), let encoded = mention.value, let value = String(base64Encoded: encoded) { + self = .mention(value, userInfo: nil) + return + } + return nil + } + + var uri: URL? { + switch self { + case .url(_, _, let url, _): + return URL(string: url) + case .hashtag(let hashtag, _): + return URL(string: "\(ActiveEntityType.appScheme)://meta?hashtag=\(hashtag.base64Encoded)") + case .mention(let mention, _): + return URL(string: "\(ActiveEntityType.appScheme)://meta?mention=\(mention.base64Encoded)") + default: + return nil + } + } + +} + +extension String { + fileprivate var base64Encoded: String { + return Data(self.utf8).base64EncodedString() + } + + init?(base64Encoded: String) { + guard let data = Data(base64Encoded: base64Encoded), + let string = String(data: data, encoding: .utf8) else { + return nil + } + self = string + } +} diff --git a/Mastodon/Helper/MastodonStatusContent.swift b/Mastodon/Helper/MastodonStatusContent.swift index 5dbef4991..1e52f150c 100755 --- a/Mastodon/Helper/MastodonStatusContent.swift +++ b/Mastodon/Helper/MastodonStatusContent.swift @@ -5,10 +5,10 @@ // Created by MainasuK Cirno on 2021/2/1. // -import Foundation +import UIKit import Combine -import Kanna import ActiveLabel +import Fuzi enum MastodonStatusContent { @@ -125,30 +125,6 @@ extension String { } } -extension MastodonStatusContent { - struct ParseResult: Hashable { - let document: String - let original: String - let trimmed: String - let activeEntities: [ActiveEntity] - - static func == (lhs: MastodonStatusContent.ParseResult, rhs: MastodonStatusContent.ParseResult) -> Bool { - return lhs.document == rhs.document - && lhs.original == rhs.original - && lhs.trimmed == rhs.trimmed - && lhs.activeEntities.count == rhs.activeEntities.count // FIXME: - } - - func hash(into hasher: inout Hasher) { - hasher.combine(document) - hasher.combine(original) - hasher.combine(trimmed) - hasher.combine(activeEntities.count) // FIXME: - } - } -} - - extension MastodonStatusContent { class Node { @@ -165,7 +141,7 @@ extension MastodonStatusContent { } let tagName: String? - let classNames: Set + let attributes: [String : String] let href: String? let hrefEllipsis: String? @@ -175,56 +151,47 @@ extension MastodonStatusContent { level: Int, text: Substring, tagName: String?, - className: String?, + attributes: [String : String], href: String?, hrefEllipsis: String?, children: [Node] ) { let _classNames: Set = { - guard let className = className else { return Set() } + guard let className = attributes["class"] else { return Set() } return Set(className.components(separatedBy: " ")) }() let _type: Type? = { - if tagName == "a" && !_classNames.contains("mention") { - return .url - } - - if _classNames.contains("mention") { + if tagName == "a" { if _classNames.contains("u-url") { return .mention - } else if _classNames.contains("hashtag") { + } + if _classNames.contains("hashtag") { return .hashtag } + return .url + } else { + if _classNames.contains("emoji") { + return .emoji + } + return nil } - - if _classNames.contains("emoji") { - return .emoji - } - - return nil }() self.level = level self.type = _type self.text = text self.tagName = tagName - self.classNames = _classNames + self.attributes = attributes self.href = href self.hrefEllipsis = hrefEllipsis self.children = children } static func parse(document: String) throws -> MastodonStatusContent.Node { - let html = try HTML(html: document, encoding: .utf8) - - // add `\r\n` explicit due to Kanna text missing it after convert to text - // ref: https://github.com/tid-kijyun/Kanna/issues/150 - let brNodes = html.css("br").makeIterator() - while let brNode = brNodes.next() { - brNode.addNextSibling(try! HTML(html: "\r\n", encoding: .utf8).body!) - } + let document = document.replacingOccurrences(of: "
|
", with: "\r\n", options: .regularExpression, range: nil) + let html = try HTMLDocument(string: document) let body = html.body ?? nil - let text = body?.text ?? "" + let text = body?.stringValue ?? "" let level = 0 let children: [MastodonStatusContent.Node] = body.flatMap { body in return Node.parse(element: body, parentText: text[...], parentLevel: level + 1) @@ -232,8 +199,8 @@ extension MastodonStatusContent { let node = Node( level: level, text: text[...], - tagName: body?.tagName, - className: body?.className, + tagName: body?.tag, + attributes: body?.attributes ?? [:], href: nil, hrefEllipsis: nil, children: children @@ -246,13 +213,11 @@ extension MastodonStatusContent { let parent = element let scanner = Scanner(string: String(parentText)) scanner.charactersToBeSkipped = .none - - var element = parent.at_css(":first-child") + var children: [Node] = [] - - while let _element = element { - let _text = _element.text ?? "" - + for _element in parent.children { + let _text = _element.stringValue + // scan element text _ = scanner.scanUpToString(_text) let startIndexOffset = scanner.currentIndex.utf16Offset(in: scanner.string) @@ -261,27 +226,26 @@ extension MastodonStatusContent { continue } let endIndexOffset = scanner.currentIndex.utf16Offset(in: scanner.string) - + // locate substring let startIndex = parentText.utf16.index(parentText.utf16.startIndex, offsetBy: startIndexOffset) let endIndex = parentText.utf16.index(parentText.utf16.startIndex, offsetBy: endIndexOffset) let text = Substring(parentText.utf16[startIndex..%@%@: %@", diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusNodeDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusNodeDelegate.swift new file mode 100644 index 000000000..b8734a3c8 --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusNodeDelegate.swift @@ -0,0 +1,16 @@ +// +// StatusProvider+StatusNodeDelegate.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-6-20. +// + +import Foundation +import ActiveLabel + +// MARK: - StatusViewDelegate +extension StatusNodeDelegate where Self: StatusProvider { + func statusNode(_ node: StatusNode, statusContentTextNode: ASMetaEditableTextNode, didSelectActiveEntityType type: ActiveEntityType) { + StatusProviderFacade.responseToStatusActiveLabelAction(provider: self, node: node, didSelectActiveEntityType: type) + } +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift index 8e27a2207..78bed66c5 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift @@ -9,6 +9,7 @@ import UIKit import Combine import CoreData import CoreDataStack +import AsyncDisplayKit protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController { // async @@ -21,4 +22,12 @@ protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewControl var tableViewDiffableDataSource: UITableViewDiffableDataSource? { get } func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? func items(indexPaths: [IndexPath]) -> [Item] + + func status(node: ASCellNode?, indexPath: IndexPath?) -> Status? +} + +extension StatusProvider { + func status(node: ASCellNode?, indexPath: IndexPath?) -> Status? { + fatalError("Needs implement this") + } } diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index a21422159..ff5b61583 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -12,6 +12,7 @@ import CoreData import CoreDataStack import MastodonSDK import ActiveLabel +import AsyncDisplayKit enum StatusProviderFacade { } @@ -144,51 +145,85 @@ extension StatusProviderFacade { break } } - + + static func responseToStatusActiveLabelAction(provider: StatusProvider, node: ASCellNode, didSelectActiveEntityType type: ActiveEntityType) { + switch type { + case .hashtag(let text, _): + let hashtagTimelienViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: text) + provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelienViewModel), from: provider, transition: .show) + case .mention(let text, _): + coordinateToStatusMentionProfileScene(for: .primary, provider: provider, node: node, mention: text) + case .url(_, _, let url, _): + guard let url = URL(string: url) else { return } + if let domain = provider.context.authenticationService.activeMastodonAuthenticationBox.value?.domain, url.host == domain, + url.pathComponents.count >= 4, + url.pathComponents[0] == "/", + url.pathComponents[1] == "web", + url.pathComponents[2] == "statuses" { + let statusID = url.pathComponents[3] + let threadViewModel = RemoteThreadViewModel(context: provider.context, statusID: statusID) + provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show) + } else { + provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) + } + default: + break + } + } + + private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, node: ASCellNode, mention: String) { + guard let status = provider.status(node: node, indexPath: nil) else { return } + coordinateToStatusMentionProfileScene(for: target, provider: provider, status: status, mention: mention) + } + private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, cell: UITableViewCell, mention: String) { - guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let domain = activeMastodonAuthenticationBox.domain - provider.status(for: cell, indexPath: nil) .sink { [weak provider] status in guard let provider = provider else { return } - let _status: Status? = { - switch target { - case .primary: return status?.reblog ?? status - case .secondary: return status - } - }() - guard let status = _status else { return } - - // cannot continue without meta - guard let mentionMeta = (status.mentions ?? Set()).first(where: { $0.username == mention }) else { return } - - let userID = mentionMeta.id - - let profileViewModel: ProfileViewModel = { - // check if self - guard userID != activeMastodonAuthenticationBox.userID else { - return MeProfileViewModel(context: provider.context) - } - - let request = MastodonUser.sortedFetchRequest - request.fetchLimit = 1 - request.predicate = MastodonUser.predicate(domain: domain, id: userID) - let mastodonUser = provider.context.managedObjectContext.safeFetch(request).first - - if let mastodonUser = mastodonUser { - return CachedProfileViewModel(context: provider.context, mastodonUser: mastodonUser) - } else { - return RemoteProfileViewModel(context: provider.context, userID: userID) - } - }() - - DispatchQueue.main.async { - provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: provider, transition: .show) - } + guard let status = status else { return } + coordinateToStatusMentionProfileScene(for: target, provider: provider, status: status, mention: mention) } .store(in: &provider.disposeBag) } + + private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, status: Status, mention: String) { + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let domain = activeMastodonAuthenticationBox.domain + + let status: Status = { + switch target { + case .primary: return status.reblog ?? status + case .secondary: return status + } + }() + + // cannot continue without meta + guard let mentionMeta = (status.mentions ?? Set()).first(where: { $0.username == mention }) else { return } + + let userID = mentionMeta.id + + let profileViewModel: ProfileViewModel = { + // check if self + guard userID != activeMastodonAuthenticationBox.userID else { + return MeProfileViewModel(context: provider.context) + } + + let request = MastodonUser.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = MastodonUser.predicate(domain: domain, id: userID) + let mastodonUser = provider.context.managedObjectContext.safeFetch(request).first + + if let mastodonUser = mastodonUser { + return CachedProfileViewModel(context: provider.context, mastodonUser: mastodonUser) + } else { + return RemoteProfileViewModel(context: provider.context, userID: userID) + } + }() + + DispatchQueue.main.async { + provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: provider, transition: .show) + } + } } extension StatusProviderFacade { diff --git a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController+DebugAction.swift new file mode 100644 index 000000000..0362d6fe3 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController+DebugAction.swift @@ -0,0 +1,383 @@ +// +// AsyncHomeTimelineViewController+DebugAction.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-21. +// + +import os.log +import UIKit +import CoreData +import CoreDataStack + +#if DEBUG +import FLEX + +extension AsyncHomeTimelineViewController { + var debugMenu: UIMenu { + let menu = UIMenu( + title: "Debug Tools", + image: nil, + identifier: nil, + options: .displayInline, + children: [ + UIAction(title: "Show FLEX", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.showFLEXAction(action) + }), + moveMenu, + dropMenu, + UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showWelcomeAction(action) + }, + UIAction(title: "Show Or Remove EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in + guard let self = self else { return } + if self.emptyView.superview != nil { + self.emptyView.removeFromSuperview() + } else { + self.showEmptyView() + } + }, + UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showPublicTimelineAction(action) + }, + UIAction(title: "Show Profile", image: UIImage(systemName: "person.crop.circle"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showProfileAction(action) + }, + UIAction(title: "Show Thread", image: UIImage(systemName: "bubble.left.and.bubble.right"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showThreadAction(action) + }, + UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showSettings(action) + }, + UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in + guard let self = self else { return } + self.signOutAction(action) + } + ] + ) + return menu + } + + var moveMenu: UIMenu { + return UIMenu( + title: "Move toโ€ฆ", + image: UIImage(systemName: "arrow.forward.circle"), + identifier: nil, + options: [], + children: [ + UIAction(title: "First Gap", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToTopGapAction(action) + }), + UIAction(title: "First Replied Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstRepliedStatus(action) + }), + UIAction(title: "First Reblog Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstReblogStatus(action) + }), + UIAction(title: "First Poll Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + 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 status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstGIFStatus(action) + }), + ] + ) + } + + var dropMenu: UIMenu { + return UIMenu( + title: "Dropโ€ฆ", + image: UIImage(systemName: "minus.circle"), + identifier: nil, + options: [], + children: [10, 50, 100, 150, 200, 250, 300].map { count in + UIAction(title: "Drop Recent \(count) Statuses", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.dropRecentStatusAction(action, count: count) + }) + } + ) + } +} + +extension AsyncHomeTimelineViewController { + + @objc private func showFLEXAction(_ sender: UIAction) { + FLEXManager.shared.showExplorer() + } + + @objc private func moveToTopGapAction(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeMiddleLoader: 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) + } + } + + @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 + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + return homeTimelineIndex.status.reblog != nil + 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 reblog status") + } + } + + @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 + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let post = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return post.poll != nil + 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 poll status") + } + } + + @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.status.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 + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return status.mediaAttachments?.contains(where: { $0.type == .audio }) ?? 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 audio status") + } + } + + @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 status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return status.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 status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return status.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() + + let droppingObjectIDs = snapshotTransitioning.itemIdentifiers.prefix(count).compactMap { item -> NSManagedObjectID? in + switch item { + case .homeTimelineIndex(let objectID, _): return objectID + default: return nil + } + } + var droppingStatusObjectIDs: [NSManagedObjectID] = [] + context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in + guard let self = self else { return } + for objectID in droppingObjectIDs { + guard let homeTimelineIndex = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { continue } + droppingStatusObjectIDs.append(homeTimelineIndex.status.objectID) + self.context.apiService.backgroundManagedObjectContext.delete(homeTimelineIndex) + } + } + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .success: + self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in + guard let self = self else { return } + for objectID in droppingStatusObjectIDs { + guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Status else { continue } + self.context.apiService.backgroundManagedObjectContext.delete(post) + } + } + .sink { _ in + // do nothing + } + .store(in: &self.disposeBag) + case .failure(let error): + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } + + @objc private func showWelcomeAction(_ sender: UIAction) { + coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil)) + } + + @objc private func showPublicTimelineAction(_ sender: UIAction) { + coordinator.present(scene: .publicTimeline, from: self, transition: .show) + } + + @objc private func showProfileAction(_ sender: UIAction) { + let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert) + alertController.addTextField() + let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in + guard let self = self else { return } + guard let textField = alertController?.textFields?.first else { return } + let profileViewModel = RemoteProfileViewModel(context: self.context, userID: textField.text ?? "") + self.coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show) + } + alertController.addAction(showAction) + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + } + + @objc private func showThreadAction(_ sender: UIAction) { + let alertController = UIAlertController(title: "Enter Status ID", message: nil, preferredStyle: .alert) + alertController.addTextField() + let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in + guard let self = self else { return } + guard let textField = alertController?.textFields?.first else { return } + let threadViewModel = RemoteThreadViewModel(context: self.context, statusID: textField.text ?? "") + self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: self, transition: .show) + } + alertController.addAction(showAction) + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + } + + @objc private func showSettings(_ sender: UIAction) { + guard let currentSetting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: currentSetting) + coordinator.present( + scene: .settings(viewModel: settingsViewModel), + from: self, + transition: .modal(animated: true, completion: nil) + ) + } + + @objc func signOutAction(_ sender: UIAction) { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + + context.authenticationService.signOutMastodonUser( + domain: activeMastodonAuthenticationBox.domain, + userID: activeMastodonAuthenticationBox.userID + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .success(let isSignOut): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail") + guard isSignOut else { return } + self.coordinator.setup() + self.coordinator.setupOnboardingIfNeeds(animated: true) + } + } + .store(in: &disposeBag) + } +} +#endif diff --git a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController+Provider.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController+Provider.swift new file mode 100644 index 000000000..c1533ce43 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController+Provider.swift @@ -0,0 +1,113 @@ +// +// AsyncHomeTimelineViewController+Provider.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-21. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import AsyncDisplayKit + +// MARK: - StatusProvider +extension AsyncHomeTimelineViewController: StatusProvider { + + func status() -> Future { + return Future { promise in promise(.success(nil)) } + } + + func status(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 ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), + 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?.status)) + } + default: + promise(.success(nil)) + } + } + } + + func status(for cell: UICollectionViewCell) -> Future { + return Future { promise in promise(.success(nil)) } + } + + var managedObjectContext: NSManagedObjectContext { + return viewModel.fetchedResultsController.managedObjectContext + } + + var tableViewDiffableDataSource: UITableViewDiffableDataSource? { + return nil + } + + func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return nil + } + + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + return nil + } + + 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 + } + + func status(node: ASCellNode?, indexPath: IndexPath?) -> Status? { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return nil + } + + guard let indexPath = indexPath ?? node.flatMap({ self.node.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .homeTimelineIndex(let objectID, _): + guard let homeTimelineIndex = try? viewModel.fetchedResultsController.managedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { + assertionFailure() + return nil + } + return homeTimelineIndex.status + default: + return nil + } + } + +} + +extension AsyncHomeTimelineViewController: UserProvider {} diff --git a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController.swift new file mode 100644 index 000000000..37f884255 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewController.swift @@ -0,0 +1,592 @@ +// +// AsyncHomeTimelineViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-21. +// + +import os.log +import UIKit +import AVKit +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK +import AlamofireImage +import AsyncDisplayKit + +#if DEBUG +import GDPerformanceView_Swift +#endif + +final class AsyncHomeTimelineViewController: ASDKViewController, NeedsDependency, MediaPreviewableViewController { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + private(set) lazy var viewModel = AsyncHomeTimelineViewModel(context: context) + + let mediaPreviewTransitionController = MediaPreviewTransitionController() + + lazy var emptyView: UIStackView = { + let emptyView = UIStackView() + emptyView.axis = .vertical + emptyView.distribution = .fill + emptyView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 54, right: 20) + emptyView.isLayoutMarginsRelativeArrangement = true + return emptyView + }() + + let titleView = HomeTimelineNavigationBarTitleView() + + let settingBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem() + barButtonItem.tintColor = Asset.Colors.brandBlue.color + barButtonItem.image = UIImage(systemName: "gear")?.withRenderingMode(.alwaysTemplate) + return barButtonItem + }() + + let composeBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem() + barButtonItem.tintColor = Asset.Colors.brandBlue.color + barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate) + return barButtonItem + }() + + var tableView: UITableView { node.view } + //let tableView: UITableView = { + // let tableView = ControlContainableTableView() + // tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.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 publishProgressView: UIProgressView = { + let progressView = UIProgressView(progressViewStyle: .bar) + progressView.alpha = 0 + return progressView + }() + + let refreshControl = UIRefreshControl() + + + override init() { + super.init(node: ASTableNode()) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension AsyncHomeTimelineViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + node.allowsSelection = true + + title = L10n.Scene.HomeTimeline.title + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + navigationItem.leftBarButtonItem = settingBarButtonItem + navigationItem.titleView = titleView + titleView.delegate = self + + viewModel.homeTimelineNavigationBarTitleViewModel.state + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + self.titleView.configure(state: state) + } + .store(in: &disposeBag) + + #if DEBUG + // long press to trigger debug menu + settingBarButtonItem.menu = debugMenu + PerformanceMonitor.shared().delegate = self + + #else + settingBarButtonItem.target = self + settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) + #endif + + navigationItem.rightBarButtonItem = composeBarButtonItem + composeBarButtonItem.target = self + composeBarButtonItem.action = #selector(AsyncHomeTimelineViewController.composeBarButtonItemPressed(_:)) + + node.view.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(AsyncHomeTimelineViewController.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), +// ]) +// +// publishProgressView.translatesAutoresizingMaskIntoConstraints = false +// view.addSubview(publishProgressView) +// NSLayoutConstraint.activate([ +// publishProgressView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), +// publishProgressView.leadingAnchor.constraint(equalTo: view.leadingAnchor), +// publishProgressView.trailingAnchor.constraint(equalTo: view.trailingAnchor), +// ]) +// +// viewModel.tableView = tableView + viewModel.tableNode = node + viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self + node.delegate = self + viewModel.setupDiffableDataSource( + tableNode: node, + dependency: self, + statusTableViewCellDelegate: self, + timelineMiddleLoaderTableViewCellDelegate: self + ) + + +// tableView.delegate = self +// tableView.prefetchDataSource = 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() + } completion: { _ in } + } + } + .store(in: &disposeBag) + +// viewModel.homeTimelineNavigationBarTitleViewModel.publishingProgress +// .receive(on: DispatchQueue.main) +// .sink { [weak self] progress in +// guard let self = self else { return } +// guard progress > 0 else { +// let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut) +// dismissAnimator.addAnimations { +// self.publishProgressView.alpha = 0 +// } +// dismissAnimator.addCompletion { _ in +// self.publishProgressView.setProgress(0, animated: false) +// } +// dismissAnimator.startAnimation() +// return +// } +// if self.publishProgressView.alpha == 0 { +// let progressAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeOut) +// progressAnimator.addAnimations { +// self.publishProgressView.alpha = 1 +// } +// progressAnimator.startAnimation() +// } +// +// self.publishProgressView.setProgress(progress, animated: true) +// } +// .store(in: &disposeBag) +// +// viewModel.timelineIsEmpty +// .receive(on: DispatchQueue.main) +// .sink { [weak self] isEmpty in +// if isEmpty { +// self?.showEmptyView() +// } else { +// self?.emptyView.removeFromSuperview() +// } +// } +// .store(in: &disposeBag) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + +// aspectViewWillAppear(animated) +// +// // needs trigger manually after onboarding dismiss +// setNeedsStatusBarAppearanceUpdate() +// +// if (viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty { +// viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) +// } + } + + 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) + +// aspectViewDidDisappear(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 AsyncHomeTimelineViewController { + func showEmptyView() { + if emptyView.superview != nil { + return + } + view.addSubview(emptyView) + emptyView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + emptyView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + emptyView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), + emptyView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor) + ]) + + if emptyView.arrangedSubviews.count > 0 { + return + } + let findPeopleButton: PrimaryActionButton = { + let button = PrimaryActionButton() + button.setTitle(L10n.Common.Controls.Actions.findPeople, for: .normal) + button.addTarget(self, action: #selector(AsyncHomeTimelineViewController.findPeopleButtonPressed(_:)), for: .touchUpInside) + return button + }() + NSLayoutConstraint.activate([ + findPeopleButton.heightAnchor.constraint(equalToConstant: 46) + ]) + + let manuallySearchButton: HighlightDimmableButton = { + let button = HighlightDimmableButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) + button.setTitle(L10n.Common.Controls.Actions.manuallySearch, for: .normal) + button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) + button.addTarget(self, action: #selector(AsyncHomeTimelineViewController.manuallySearchButtonPressed(_:)), for: .touchUpInside) + return button + }() + + emptyView.addArrangedSubview(findPeopleButton) + emptyView.setCustomSpacing(17, after: findPeopleButton) + emptyView.addArrangedSubview(manuallySearchButton) + + } +} + +extension AsyncHomeTimelineViewController { + + @objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) { + let viewModel = SuggestionAccountViewModel(context: context) + viewModel.delegate = self.viewModel + coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil)) + } + + @objc private func manuallySearchButtonPressed(_ sender: UIButton) { + coordinator.switchToTabBar(tab: .search) + } + + @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard let setting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: setting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) + } + + @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + let composeViewModel = ComposeViewModel(context: context, composeKind: .post) + coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) + } + + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + guard viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) else { + sender.endRefreshing() + return + } + } + +} + +// MARK: - StatusTableViewControllerAspect +//extension AsyncHomeTimelineViewController: StatusTableViewControllerAspect { } + +//extension AsyncHomeTimelineViewController: TableViewCellHeightCacheableContainer { +// var cellFrameCache: NSCache { return viewModel.cellFrameCache } +//} + +// MARK: - UIScrollViewDelegate +extension AsyncHomeTimelineViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + + //aspectScrollViewDidScroll(scrollView) + viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) + } +} + +//extension AsyncHomeTimelineViewController: LoadMoreConfigurableTableViewContainer { +// typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell +// typealias LoadingState = HomeTimelineViewModel.LoadOldestState.Loading +// var loadMoreConfigurableTableView: UITableView { return tableView } +// var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine } +//} + +// MARK: - UITableViewDelegate +//extension AsyncHomeTimelineViewController: UITableViewDelegate { +// +// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { +// aspectTableView(tableView, estimatedHeightForRowAt: indexPath) +// } +// +// func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { +// aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) +// } +// +// func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { +// aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) +// } +// +// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { +// aspectTableView(tableView, didSelectRowAt: indexPath) +// } +// +// func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { +// return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) +// } +// +// func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { +// return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) +// } +// +// func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { +// return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) +// } +// +// func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { +// aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) +// } +// +//} + +// MARK: - UITableViewDataSourcePrefetching +//extension AsyncHomeTimelineViewController: UITableViewDataSourcePrefetching { +// func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { +// aspectTableView(tableView, prefetchRowsAt: indexPaths) +// } +//} + +// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate +extension AsyncHomeTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { + func navigationBar() -> UINavigationBar? { + return navigationController?.navigationBar + } +} + +// MARK: - TimelineMiddleLoaderTableViewCellDelegate +extension AsyncHomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) { + guard let upperTimelineIndexObjectID = timelineIndexobjectID else { + return + } + viewModel.loadMiddleSateMachineList + .receive(on: DispatchQueue.main) + .sink { [weak self] ids in + guard let _ = 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 + if isLoading { + cell.startAnimating() + } else { + cell.stopAnimating() + } + } else { + cell.stopAnimating() + } + } + .store(in: &cell.disposeBag) + + var dict = viewModel.loadMiddleSateMachineList.value + if let _ = dict[upperTimelineIndexObjectID] { + // do nothing + } else { + let stateMachine = GKStateMachine(states: [ + AsyncHomeTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID), + AsyncHomeTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID), + AsyncHomeTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperTimelineIndexObjectID: upperTimelineIndexObjectID), + AsyncHomeTimelineViewModel.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 AsyncHomeTimelineViewController: 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 } + node.scrollToRow(at: indexPath, at: .top, animated: true) + } + } + +} + +// MARK: - AVPlayerViewControllerDelegate +extension AsyncHomeTimelineViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + +// MARK: - StatusTableViewCellDelegate +extension AsyncHomeTimelineViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} + +// MARK: - HomeTimelineNavigationBarTitleViewDelegate +extension AsyncHomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate { + func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, logoButtonDidPressed sender: UIButton) { + scrollToTop(animated: true) + } + + func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) { + switch titleView.state { + case .newPostButton: + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let indexPath = IndexPath(row: 0, section: 0) + guard diffableDataSource.itemIdentifier(for: indexPath) != nil else { return } + node.scrollToRow(at: indexPath, at: .top, animated: true) + case .offlineButton: + // TODO: retry + break + case .publishedButton: + break + default: + break + } + } +} + +extension AsyncHomeTimelineViewController { + override var keyCommands: [UIKeyCommand]? { + return navigationKeyCommands + statusNavigationKeyCommands + } +} + +// MARK: - StatusTableViewControllerNavigateable +extension AsyncHomeTimelineViewController: StatusTableViewControllerNavigateable { + @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { + navigateKeyCommandHandler(sender) + } + + @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { + statusKeyCommandHandler(sender) + } +} + +#if DEBUG +extension AsyncHomeTimelineViewController: PerformanceMonitorDelegate { + func performanceMonitor(didReport performanceReport: PerformanceReport) { + // print(performanceReport) + } +} +#endif + +// MARK: - ASTableDelegate +extension AsyncHomeTimelineViewController: ASTableDelegate { + func shouldBatchFetch(for tableNode: ASTableNode) -> Bool { + switch viewModel.loadLatestStateMachine.currentState { + case is HomeTimelineViewModel.LoadOldestState.NoMore: + return false + default: + return true + } + } + + func tableNode(_ tableNode: ASTableNode, willBeginBatchFetchWith context: ASBatchContext) { + viewModel.loadoldestStateMachine.enter(HomeTimelineViewModel.LoadOldestState.Loading.self) + context.completeBatchFetching(true) + } + + func tableNode(_ tableNode: ASTableNode, willDisplayRowWith node: ASCellNode) { + if let statusNode = node as? StatusNode { + statusNode.delegate = self + } + } +} + +// MARK: - StatusNodeDelegate +extension AsyncHomeTimelineViewController: StatusNodeDelegate { } diff --git a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+Diffable.swift new file mode 100644 index 000000000..90d5d9f46 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+Diffable.swift @@ -0,0 +1,155 @@ +// +// AsyncHomeTimelineViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-21. +// + +import os.log +import UIKit +import CoreData +import CoreDataStack +import AsyncDisplayKit +import DifferenceKit +import DiffableDataSources + +extension AsyncHomeTimelineViewModel { + + func setupDiffableDataSource( + tableNode: ASTableNode, + dependency: NeedsDependency, + statusTableViewCellDelegate: StatusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate + ) { + tableNode.automaticallyAdjustsContentOffset = true + + diffableDataSource = StatusSection.tableNodeDiffableDataSource( + tableNode: tableNode, + managedObjectContext: fetchedResultsController.managedObjectContext + ) + + var snapshot = DiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + } + +} + +// MARK: - NSFetchedResultsControllerDelegate +extension AsyncHomeTimelineViewModel: 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 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.StatusAttribute] = [:] + + 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.StatusAttribute() + attribute.isSeparatorLineHidden = false + + // append new item into snapshot + newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute)) + + let isLast = i == timelineIndexes.count - 1 + switch (isLast, timelineIndex.hasMore) { + case (false, true): + newTimelineItems.append(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: timelineIndex.objectID)) + attribute.isSeparatorLineHidden = true + case (true, true): + shouldAddBottomLoader = true + default: + break + } + } // end for + + var newSnapshot = DiffableDataSourceSnapshot() + newSnapshot.appendSections([.main]) + newSnapshot.appendItems(newTimelineItems, toSection: .main) + + let endSnapshot = CACurrentMediaTime() + + if shouldAddBottomLoader, !(self.loadoldestStateMachine.currentState is LoadOldestState.NoMore) { + newSnapshot.appendItems([.bottomLoader], toSection: .main) + } + + diffableDataSource.apply(newSnapshot, animatingDifferences: false) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + 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: DiffableDataSourceSnapshot, + newSnapshot: DiffableDataSourceSnapshot + ) -> 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/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadLatestState.swift new file mode 100644 index 000000000..4f3aaadc3 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadLatestState.swift @@ -0,0 +1,130 @@ +// +// AsyncHomeTimelineViewModel+LoadLatestState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-21. +// +// + +import os.log +import func QuartzCore.CACurrentMediaTime +import Foundation +import CoreData +import CoreDataStack +import GameplayKit + +extension AsyncHomeTimelineViewModel { + class LoadLatestState: GKState { + weak var viewModel: AsyncHomeTimelineViewModel? + + init(viewModel: AsyncHomeTimelineViewModel) { + 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 AsyncHomeTimelineViewModel.LoadLatestState { + class Initial: AsyncHomeTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: AsyncHomeTimelineViewModel.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 { + // sign out when loading will enter here + 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 latestStatusIDs: [Status.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) + latestStatusIDs = timelineIndexes + .prefix(APIService.onceRequestStatusMaxCount) // avoid performance issue + .compactMap { timelineIndex in + timelineIndex.value(forKeyPath: #keyPath(HomeTimelineIndex.status.id)) as? Status.ID + } + } catch { + stateMachine.enter(Fail.self) + return + } + + let end = CACurrentMediaTime() + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect statuses 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 + viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) + 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 statuses 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 statuses + let statuses = response.value + let newStatuses = statuses.filter { !latestStatusIDs.contains($0.id) } + os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, newStatuses.count) + + if newStatuses.isEmpty { + viewModel.isFetchingLatestTimeline.value = false + } else { + if !latestStatusIDs.isEmpty { + viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() + } + } + viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty + } + .store(in: &viewModel.disposeBag) + } + } + } + + class Fail: AsyncHomeTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: AsyncHomeTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + +} diff --git a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadMiddleState.swift new file mode 100644 index 000000000..e25f60ca9 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadMiddleState.swift @@ -0,0 +1,108 @@ +// +// AsyncHomeTimelineViewModel+LoadMiddleState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-21. +// + +import os.log +import Foundation +import GameplayKit +import CoreData +import CoreDataStack + +extension AsyncHomeTimelineViewModel { + class LoadMiddleState: GKState { + weak var viewModel: AsyncHomeTimelineViewModel? + let upperTimelineIndexObjectID: NSManagedObjectID + + init(viewModel: AsyncHomeTimelineViewModel, 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 AsyncHomeTimelineViewModel.LoadMiddleState { + + class Initial: AsyncHomeTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: AsyncHomeTimelineViewModel.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 statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { timelineIndex in + timelineIndex.status.id + } + + // TODO: only set large count when using Wi-Fi + let maxID = timelineIndex.status.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 + viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) + switch completion { + case .failure(let error): + // TODO: handle error + os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + let statuses = response.value + let newStatuses = statuses.filter { !statusIDs.contains($0.id) } + os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld statuses, %{public}%ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, statuses.count, newStatuses.count) + if newStatuses.isEmpty { + stateMachine.enter(Fail.self) + } else { + stateMachine.enter(Success.self) + } + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: AsyncHomeTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return stateClass == Loading.self + } + } + + class Success: AsyncHomeTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return false + } + } + +} diff --git a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadOldestState.swift new file mode 100644 index 000000000..8a58cd722 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel+LoadOldestState.swift @@ -0,0 +1,113 @@ +// +// AsyncHomeTimelineViewModel+LoadOldestState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-21. +// + +import os.log +import Foundation +import GameplayKit + +extension AsyncHomeTimelineViewModel { + class LoadOldestState: GKState { + weak var viewModel: AsyncHomeTimelineViewModel? + + init(viewModel: AsyncHomeTimelineViewModel) { + 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 AsyncHomeTimelineViewModel.LoadOldestState { + class Initial: AsyncHomeTimelineViewModel.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: AsyncHomeTimelineViewModel.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.status.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 + viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { response in + let statuses = response.value + // enter no more state when no new statuses + if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) { + stateMachine.enter(NoMore.self) + } else { + stateMachine.enter(Idle.self) + } + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: AsyncHomeTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: AsyncHomeTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class NoMore: AsyncHomeTimelineViewModel.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 + } + DispatchQueue.main.async { + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } + } + } +} diff --git a/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel.swift new file mode 100644 index 000000000..3b5013a8c --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/AsyncHomeTimeline/AsyncHomeTimelineViewModel.swift @@ -0,0 +1,148 @@ +// +// AsyncHomeTimelineViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-21. +// +// + +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 +import AsyncDisplayKit + +final class AsyncHomeTimelineViewModel: 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() + let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel + + weak var tableNode: ASTableNode? + weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? + //weak var tableView: UITableView? + weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? + + let timelineIsEmpty = CurrentValueSubject(false) + let homeTimelineNeedRefresh = PassthroughSubject() + + // output + var diffableDataSource: TableNodeDiffableDataSource? + + // 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.status)] + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context.managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context) + 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 { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + + context.authenticationService.activeMastodonAuthentication + .sink { [weak self] activeMastodonAuthentication in + guard let self = self else { return } + guard let mastodonAuthentication = activeMastodonAuthentication else { return } + let activeMastodonUserID = mastodonAuthentication.userID + let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + HomeTimelineIndex.predicate(userID: activeMastodonUserID), + HomeTimelineIndex.notDeleted() + ]) + self.timelinePredicate.value = predicate + } + .store(in: &disposeBag) + + homeTimelineNeedRefresh + .sink { [weak self] _ in + self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self) + } + .store(in: &disposeBag) + + homeTimelineNavigationBarTitleViewModel.isPublished + .sink { [weak self] isPublished in + guard let self = self else { return } + self.homeTimelineNeedRefresh.send() + } + .store(in: &disposeBag) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension AsyncHomeTimelineViewModel: SuggestionAccountViewModelDelegate { } diff --git a/Mastodon/Scene/Share/View/Node/ASMetaEditableTextNode.swift b/Mastodon/Scene/Share/View/Node/ASMetaEditableTextNode.swift new file mode 100644 index 000000000..e98e81d57 --- /dev/null +++ b/Mastodon/Scene/Share/View/Node/ASMetaEditableTextNode.swift @@ -0,0 +1,21 @@ +// +// ASMetaEditableTextNode.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-6-20. +// + +import UIKit +import AsyncDisplayKit + +protocol ASMetaEditableTextNodeDelegate: AnyObject { + func metaEditableTextNode(_ textNode: ASMetaEditableTextNode, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool +} + +final class ASMetaEditableTextNode: ASEditableTextNode, UITextViewDelegate { + weak var metaEditableTextNodeDelegate: ASMetaEditableTextNodeDelegate? + + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + return metaEditableTextNodeDelegate?.metaEditableTextNode(self, shouldInteractWith: URL, in: characterRange, interaction: interaction) ?? false + } +} diff --git a/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift b/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift new file mode 100644 index 000000000..9ddbcf87b --- /dev/null +++ b/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift @@ -0,0 +1,238 @@ +// +// StatusNNode.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-6-19. +// + +import UIKit +import Combine +import AsyncDisplayKit +import CoreDataStack +import ActiveLabel +import func AVFoundation.AVMakeRect + +protocol StatusNodeDelegate: AnyObject { + func statusNode(_ node: StatusNode, statusContentTextNode: ASMetaEditableTextNode, didSelectActiveEntityType type: ActiveEntityType) +} + +final class StatusNode: ASCellNode { + + var disposeBag = Set() + var timestamp: Date + var timestampSubscription: AnyCancellable? + + weak var delegate: StatusNodeDelegate? // needs assign on main queue + + static let avatarImageSize = CGSize(width: 42, height: 42) + static let avatarImageCornerRadius: CGFloat = 4 + + static let statusContentAppearance: MastodonStatusContent.Appearance = { + let linkAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)), + .foregroundColor: Asset.Colors.brandBlue.color + ] + return MastodonStatusContent.Appearance( + attributes: [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)), + .foregroundColor: Asset.Colors.Label.primary.color + ], + urlAttributes: linkAttributes, + hashtagAttributes: linkAttributes, + mentionAttributes: linkAttributes + ) + }() + + let avatarImageNode: ASNetworkImageNode = { + let node = ASNetworkImageNode() + node.contentMode = .scaleAspectFill + node.defaultImage = UIImage.placeholder(color: .systemFill) + node.forcedSize = StatusNode.avatarImageSize + node.cornerRadius = StatusNode.avatarImageCornerRadius + // node.cornerRoundingType = .precomposited + // node.shouldRenderProgressImages = true + return node + }() + let nameTextNode = ASTextNode() + let nameDotTextNode = ASTextNode() + let dateTextNode = ASTextNode() + let usernameTextNode = ASTextNode() + let statusContentTextNode: ASMetaEditableTextNode = { + let node = ASMetaEditableTextNode() + node.scrollEnabled = false + return node + }() + + let mosaicImageViewModel: MosaicImageViewModel + let mediaMultiplexImageNodes: [ASMultiplexImageNode] + + init(status: Status) { + timestamp = (status.reblog ?? status).createdAt + let _mosaicImageViewModel: MosaicImageViewModel = { + let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } + return MosaicImageViewModel(mediaAttachments: mediaAttachments) + }() + mosaicImageViewModel = _mosaicImageViewModel + mediaMultiplexImageNodes = { + var imageNodes: [ASMultiplexImageNode] = [] + for _ in 0..<_mosaicImageViewModel.metas.count { + let imageNode = ASMultiplexImageNode() // TODO: adapt downloader + imageNode.downloadsIntermediateImages = true + imageNode.imageIdentifiers = ["url", "previewURL"].map { $0 as NSString } // quality in descending order + imageNodes.append(imageNode) + } + return imageNodes + }() + super.init() + + print("meta: \(mosaicImageViewModel.metas.count), nodes: \(mediaMultiplexImageNodes.count)") + automaticallyManagesSubnodes = true + + if let url = (status.reblog ?? status).author.avatarImageURL() { + avatarImageNode.url = url + } + + nameTextNode.attributedText = NSAttributedString(string: status.author.displayNameWithFallback, attributes: [ + .foregroundColor: Asset.Colors.Label.primary.color, + .font: UIFont.systemFont(ofSize: 17, weight: .semibold) + ]) + nameDotTextNode.attributedText = NSAttributedString(string: "ยท", attributes: [ + .foregroundColor: Asset.Colors.Label.secondary.color, + .font: UIFont.systemFont(ofSize: 13, weight: .regular) + ]) + // set date + dateTextNode.attributedText = NSAttributedString(string: timestamp.slowedTimeAgoSinceNow, attributes: [ + .foregroundColor: Asset.Colors.Label.secondary.color, + .font: UIFont.systemFont(ofSize: 13, weight: .regular) + ]) + + usernameTextNode.attributedText = NSAttributedString(string: "@" + status.author.acct, attributes: [ + .foregroundColor: Asset.Colors.Label.secondary.color, + .font: UIFont.systemFont(ofSize: 15, weight: .regular) + ]) + + statusContentTextNode.metaEditableTextNodeDelegate = self + if let parseResult = try? MastodonStatusContent.parse( + content: (status.reblog ?? status).content, + emojiDict: (status.reblog ?? status).emojiDict + ) { + statusContentTextNode.attributedText = parseResult.trimmedAttributedString(appearance: StatusNode.statusContentAppearance) + } + + for imageNode in mediaMultiplexImageNodes { + imageNode.dataSource = self + } + } + + override func didEnterDisplayState() { + super.didEnterDisplayState() + + timestampSubscription = AppContext.shared.timestampUpdatePublisher + .sink { [weak self] _ in + guard let self = self else { return } + self.dateTextNode.attributedText = NSAttributedString(string: self.timestamp.slowedTimeAgoSinceNow, attributes: [ + .foregroundColor: Asset.Colors.Label.secondary.color, + .font: UIFont.systemFont(ofSize: 13, weight: .regular) + ]) + } + + // FIXME: needs move to other only once called callback in life cycle like: `viewDidLoad` + statusContentTextNode.textView.isEditable = false + statusContentTextNode.textView.textDragInteraction?.isEnabled = false + statusContentTextNode.textView.linkTextAttributes = [ + .foregroundColor: Asset.Colors.brandBlue.color + ] + } + + override func didExitVisibleState() { + super.didExitVisibleState() + timestampSubscription = nil + } + + override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { + let headerStack = ASStackLayoutSpec.horizontal() + headerStack.alignItems = .center + headerStack.spacing = 5 + var headerStackChildren: [ASLayoutElement] = [] + + avatarImageNode.style.preferredSize = StatusNode.avatarImageSize + headerStackChildren.append(avatarImageNode) + + let authorMetaHeaderStack = ASStackLayoutSpec.horizontal() + authorMetaHeaderStack.alignItems = .center + authorMetaHeaderStack.spacing = 4 + authorMetaHeaderStack.children = [ + nameTextNode, + nameDotTextNode, + dateTextNode, + ] + let authorMetaStack = ASStackLayoutSpec.vertical() + authorMetaStack.children = [ + authorMetaHeaderStack, + usernameTextNode, + ] + + headerStackChildren.append(authorMetaStack) + + headerStack.children = headerStackChildren + + let verticalStack = ASStackLayoutSpec.vertical() + verticalStack.spacing = 10 + var verticalStackChildren: [ASLayoutElement] = [ + headerStack, + statusContentTextNode, + ] + if !mediaMultiplexImageNodes.isEmpty { + for (imageNode, meta) in zip(mediaMultiplexImageNodes, mosaicImageViewModel.metas) { + imageNode.style.preferredSize = AVMakeRect(aspectRatio: meta.size, insideRect: CGRect(origin: .zero, size: constrainedSize.max)).size + let layout = ASRatioLayoutSpec(ratio: meta.size.height / meta.size.width, child: imageNode) + verticalStackChildren.append(layout) + } + } + verticalStack.children = verticalStackChildren + + return verticalStack + } + +} + +//extension StatusNode: ASImageDownloaderProtocol { +// func downloadImage(with URL: URL, callbackQueue: DispatchQueue, downloadProgress: ASImageDownloaderProgress?, completion: @escaping ASImageDownloaderCompletion) -> Any? { +// +// } +// +// func cancelImageDownload(forIdentifier downloadIdentifier: Any) { +// +// } +//} + +// MARK: - ASEditableTextNodeDelegate +extension StatusNode: ASMetaEditableTextNodeDelegate { + func metaEditableTextNode(_ textNode: ASMetaEditableTextNode, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + guard let activityEntityType = ActiveEntityType(url: URL) else { + return false + } + defer { + delegate?.statusNode(self, statusContentTextNode: textNode, didSelectActiveEntityType: activityEntityType) + } + return false + } +} + +// MARK: - ASMultiplexImageNodeDataSource +extension StatusNode: ASMultiplexImageNodeDataSource { + func multiplexImageNode(_ imageNode: ASMultiplexImageNode, urlForImageIdentifier imageIdentifier: ASImageIdentifier) -> URL? { + guard let imageNodeIndex = mediaMultiplexImageNodes.firstIndex(of: imageNode) else { return nil } + guard imageNodeIndex < mosaicImageViewModel.metas.count else { return nil } + let meta = mosaicImageViewModel.metas[imageNodeIndex] + switch imageIdentifier { + case "url" as NSString: + return meta.url + case "previewURL" as NSString: + return meta.priviewURL + default: + assertionFailure() + return nil + } + } +} diff --git a/Mastodon/Scene/Share/View/Node/Status/TimelineBottomLoaderNode.swift b/Mastodon/Scene/Share/View/Node/Status/TimelineBottomLoaderNode.swift new file mode 100644 index 000000000..aeff71e4f --- /dev/null +++ b/Mastodon/Scene/Share/View/Node/Status/TimelineBottomLoaderNode.swift @@ -0,0 +1,37 @@ +// +// TimelineBottomLoaderNode.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-6-19. +// + +import UIKit +import AsyncDisplayKit + +final class TimelineBottomLoaderNode: ASCellNode { + + let activityIndicatorNode = ActivityIndicatorNode() + + override init() { + super.init() + + automaticallyManagesSubnodes = true + activityIndicatorNode.bounds = CGRect(x: 0, y: 0, width: 40, height: 40) + } + + override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { + let contentStack = ASStackLayoutSpec.horizontal() + contentStack.alignItems = .center + contentStack.spacing = 7 + + contentStack.children = [activityIndicatorNode] + + return contentStack + } + + override func didEnterDisplayState() { + super.didEnterDisplayState() + activityIndicatorNode.animating = true + } + +} diff --git a/Mastodon/Scene/Share/View/Node/Status/TimelineMiddleLoaderNode.swift b/Mastodon/Scene/Share/View/Node/Status/TimelineMiddleLoaderNode.swift new file mode 100644 index 000000000..33a15dd75 --- /dev/null +++ b/Mastodon/Scene/Share/View/Node/Status/TimelineMiddleLoaderNode.swift @@ -0,0 +1,50 @@ +// +// TimelineMiddleLoaderNode.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-6-19. +// + +import UIKit +import AsyncDisplayKit + +final class TimelineMiddleLoaderNode: ASCellNode { + + static let loadButtonFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .medium)) + + let activityIndicatorNode = ASDisplayNode(viewBlock: { + let view = UIActivityIndicatorView(style: .medium) + view.hidesWhenStopped = true + return view + }) + + let loadButtonNode = ASButtonNode() + + override init() { + super.init() + + automaticallyManagesSubnodes = true + + loadButtonNode.setAttributedTitle( + NSAttributedString( + string: L10n.Common.Controls.Timeline.Loader.loadMissingPosts, + attributes: [ + .foregroundColor: Asset.Colors.brandBlue.color, + .font: TimelineMiddleLoaderNode.loadButtonFont + ]), + for: .normal + ) + } + + override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { + let contentStack = ASStackLayoutSpec.horizontal() + contentStack.alignItems = .center + contentStack.spacing = 7 + + contentStack.children = [loadButtonNode] + + + return contentStack + } + +} diff --git a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift index 888d4dffe..265ce245b 100644 --- a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift @@ -16,16 +16,14 @@ struct MosaicImageViewModel { init(mediaAttachments: [Attachment]) { var metas: [MosaicMeta] = [] for element in mediaAttachments where element.type == .image { - // Display original on the iPad/Mac - guard let previewURL = element.previewURL else { continue } - let urlString = UIDevice.current.userInterfaceIdiom == .phone ? previewURL : element.url guard let meta = element.meta, let width = meta.original?.width, let height = meta.original?.height, - let url = URL(string: urlString) else { + let url = URL(string: element.url) else { continue } let mosaicMeta = MosaicMeta( + priviewURL: element.previewURL.flatMap { URL(string: $0) }, url: url, size: CGSize(width: width, height: height), blurhash: element.blurhash, @@ -40,7 +38,8 @@ struct MosaicImageViewModel { struct MosaicMeta { static let edgeMaxLength: CGFloat = 20 - + + let priviewURL: URL? let url: URL let size: CGSize let blurhash: String? diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index b6b0cdb55..416053b41 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -44,6 +44,10 @@ class AppContext: ObservableObject { private var documentStoreSubscription: AnyCancellable! let overrideTraitCollection = CurrentValueSubject(nil) + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() init() { let _coreDataStack = CoreDataStack() diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index e6ccaaac0..71073729e 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -9,6 +9,7 @@ import os.log import UIKit import UserNotifications import AppShared +import AsyncDisplayKit #if DEBUG import GDPerformanceView_Swift @@ -32,7 +33,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { application.registerForRemoteNotifications() #if DEBUG - PerformanceMonitor.shared().start() + // PerformanceMonitor.shared().start() + // ASDisplayNode.shouldShowRangeDebugOverlay = true + // ASControlNode.enableHitTestDebug = true + // ASImageNode.shouldShowImageScalingOverlay = true #endif return true diff --git a/Mastodon/Vender/ActivityIndicatorNode.swift b/Mastodon/Vender/ActivityIndicatorNode.swift new file mode 100644 index 000000000..6d34072f3 --- /dev/null +++ b/Mastodon/Vender/ActivityIndicatorNode.swift @@ -0,0 +1,71 @@ +// ref: https://github.com/Adlai-Holler/ASDKPlaceholderTest/blob/eea9fa7cff2d16a57efb47d208422ea9b49a630a/ASDKPlaceholderTest/ASDisplayNodeSubclasses.swift + +import Foundation +import AsyncDisplayKit +import UIKit + +/** + A node that shows a `UIActivityIndicatorView`. Does not support layer backing. + Note: You must not change the style to or from `.WhiteLarge` after init, or the node's size will not update. + */ +class ActivityIndicatorNode: ASDisplayNode { + + private static let defaultSize = CGSize(width: 20, height: 20) + private static let largeSize = CGSize(width: 37, height: 37) + + init(style: UIActivityIndicatorView.Style = .medium) { + super.init() + setViewBlock { + UIActivityIndicatorView(style: style) + } + + self.style.preferredSize = style == .large ? ActivityIndicatorNode.defaultSize : ActivityIndicatorNode.largeSize + } + + var activityIndicatorView: UIActivityIndicatorView { + return view as! UIActivityIndicatorView + } + + override func didLoad() { + super.didLoad() + if animating { + activityIndicatorView.startAnimating() + } + activityIndicatorView.color = color + activityIndicatorView.hidesWhenStopped = hidesWhenStopped + } + + /// Wrapper for `UIActivityIndicatorView.hidesWhenStopped`. NOTE: You must respect thread affinity. + var hidesWhenStopped = true { + didSet { + if isNodeLoaded { + assert(Thread.isMainThread) + activityIndicatorView.hidesWhenStopped = hidesWhenStopped + } + } + } + + /// Wrapper for `UIActivityIndicatorView.color`. NOTE: You must respect thread affinity. + var color: UIColor? { + didSet { + if isNodeLoaded { + assert(Thread.isMainThread) + activityIndicatorView.color = color + } + } + } + + /// Wrapper for `UIActivityIndicatorView.animating`. NOTE: You must respect thread affinity. + var animating = false { + didSet { + if isNodeLoaded { + assert(Thread.isMainThread) + if animating { + activityIndicatorView.startAnimating() + } else { + activityIndicatorView.stopAnimating() + } + } + } + } +} diff --git a/Podfile b/Podfile index 796473d68..d888d37f4 100644 --- a/Podfile +++ b/Podfile @@ -8,7 +8,8 @@ target 'Mastodon' do # UI pod 'UITextField+Shake', '~> 1.2' - + pod 'Texture', '~> 3.0.0' + # misc pod 'SwiftGen', '~> 6.4.0' pod 'DateToolsSwift', '~> 5.0.0' diff --git a/Podfile.lock b/Podfile.lock index 4e7baf347..9370907f9 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -4,7 +4,42 @@ PODS: - GDPerformanceView-Swift (2.1.1) - Kanna (5.2.4) - Keys (1.0.1) + - PINCache (3.0.3): + - PINCache/Arc-exception-safe (= 3.0.3) + - PINCache/Core (= 3.0.3) + - PINCache/Arc-exception-safe (3.0.3): + - PINCache/Core + - PINCache/Core (3.0.3): + - PINOperation (~> 1.2.1) + - PINOperation (1.2.1) + - PINRemoteImage/Core (3.0.3): + - PINOperation + - PINRemoteImage/iOS (3.0.3): + - PINRemoteImage/Core + - PINRemoteImage/PINCache (3.0.3): + - PINCache (~> 3.0.3) + - PINRemoteImage/Core - SwiftGen (6.4.0) + - Texture (3.0.0): + - Texture/AssetsLibrary (= 3.0.0) + - Texture/Core (= 3.0.0) + - Texture/MapKit (= 3.0.0) + - Texture/Photos (= 3.0.0) + - Texture/PINRemoteImage (= 3.0.0) + - Texture/Video (= 3.0.0) + - Texture/AssetsLibrary (3.0.0): + - Texture/Core + - Texture/Core (3.0.0) + - Texture/MapKit (3.0.0): + - Texture/Core + - Texture/Photos (3.0.0): + - Texture/Core + - Texture/PINRemoteImage (3.0.0): + - PINRemoteImage/iOS (~> 3.0.0) + - PINRemoteImage/PINCache + - Texture/Core + - Texture/Video (3.0.0): + - Texture/Core - "UITextField+Shake (1.2.1)" DEPENDENCIES: @@ -14,6 +49,7 @@ DEPENDENCIES: - Kanna (~> 5.2.2) - Keys (from `Pods/CocoaPodsKeys`) - SwiftGen (~> 6.4.0) + - Texture (~> 3.0.0) - "UITextField+Shake (~> 1.2)" SPEC REPOS: @@ -22,7 +58,11 @@ SPEC REPOS: - FLEX - GDPerformanceView-Swift - Kanna + - PINCache + - PINOperation + - PINRemoteImage - SwiftGen + - Texture - "UITextField+Shake" EXTERNAL SOURCES: @@ -35,9 +75,13 @@ SPEC CHECKSUMS: GDPerformanceView-Swift: 22d964fe40b19e3d914dba2586237d064de8fd77 Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9 + PINCache: 7a8fc1a691173d21dbddbf86cd515de6efa55086 + PINOperation: 00c935935f1e8cf0d1e2d6b542e75b88fc3e5e20 + PINRemoteImage: f1295b29f8c5e640e25335a1b2bd9d805171bd01 SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 + Texture: 2f109e937850d94d1d07232041c9c7313ccddb81 "UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3 -PODFILE CHECKSUM: 257c550231fcd1336a29f7835aa331171bb66ebd +PODFILE CHECKSUM: ffa234348a25b078905180858ee4119fec0712d1 COCOAPODS: 1.10.1