From 1a3135b9981c3e6db930ee5186399abb83c8f843 Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 19 Jun 2021 18:33:29 +0800 Subject: [PATCH 1/4] feat: [WIP] migrate to Texture (AsyncDisplayKit) for better performance --- Mastodon.xcodeproj/project.pbxproj | 78 ++++ .../xcschemes/xcschememanagement.plist | 8 +- .../xcshareddata/swiftpm/Package.resolved | 18 + .../Diffiable/DataSource/ASTableNode.swift | 81 ++++ .../TableNodeDiffableDataSource.swift | 111 ++++++ Mastodon/Diffiable/Item/Item.swift | 3 + .../Diffiable/Section/StatusSection.swift | 28 ++ .../NSDiffableDataSourceSnapshot.swift | 24 ++ ...meTimelineViewController+DebugAction.swift | 29 +- .../HomeTimelineViewController+Provider.swift | 2 +- .../HomeTimelineViewController.swift | 377 +++++++++--------- .../HomeTimelineViewModel+Diffable.swift | 92 ++--- .../HomeTimeline/HomeTimelineViewModel.swift | 15 +- .../Scene/Share/View/Node/StatusNode.swift | 109 +++++ .../View/Node/TimelineBottomLoaderNode.swift | 37 ++ .../View/Node/TimelineMiddleLoaderNode.swift | 50 +++ Mastodon/State/AppContext.swift | 4 + Mastodon/Supporting Files/AppDelegate.swift | 2 + Mastodon/Vender/ActivityIndicatorNode.swift | 71 ++++ Podfile | 3 +- Podfile.lock | 46 ++- 21 files changed, 933 insertions(+), 255 deletions(-) create mode 100644 Mastodon/Diffiable/DataSource/ASTableNode.swift create mode 100644 Mastodon/Diffiable/DataSource/TableNodeDiffableDataSource.swift create mode 100644 Mastodon/Extension/NSDiffableDataSourceSnapshot.swift create mode 100644 Mastodon/Scene/Share/View/Node/StatusNode.swift create mode 100644 Mastodon/Scene/Share/View/Node/TimelineBottomLoaderNode.swift create mode 100644 Mastodon/Scene/Share/View/Node/TimelineMiddleLoaderNode.swift create mode 100644 Mastodon/Vender/ActivityIndicatorNode.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 98caf2775..5201c5409 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -407,6 +407,15 @@ 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 */; }; 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 */; }; @@ -981,6 +990,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 = ""; }; @@ -1065,8 +1081,10 @@ DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */, DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */, + DBAC6483267D0B21007FE9FD /* DifferenceKit in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator 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 */, @@ -1337,6 +1355,7 @@ DB51D171262832380062B7A1 /* BlurHashEncode.swift */, DB6180EC26391C6C0018D199 /* TransitioningMath.swift */, DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */, + DBAC649A267DF8C8007FE9FD /* ActivityIndicatorNode.swift */, ); path = Vender; sourceTree = ""; @@ -1414,6 +1433,7 @@ 2D76319D25C151F600929FB9 /* Section */, 2D7631B125C159E700929FB9 /* Item */, DBCBED2226132E1D00B49291 /* FetchedResultsController */, + DBAC6490267DC84F007FE9FD /* DataSource */, ); path = Diffiable; sourceTree = ""; @@ -1464,6 +1484,7 @@ DB87D45C2609DE6600D12C0D /* TextField */, DB1D187125EF5BBD003F1F23 /* TableView */, 2D7631A625C1533800929FB9 /* TableviewCell */, + DBAC6486267D0FAC007FE9FD /* Node */, ); path = View; sourceTree = ""; @@ -2167,6 +2188,7 @@ DBCC3B35261440BA0045B23D /* UINavigationController.swift */, DB6D1B23263684C600ACB481 /* UserDefaults.swift */, DB97131E2666078B00BD1E90 /* Date.swift */, + DBAC6489267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift */, ); path = Extension; sourceTree = ""; @@ -2328,6 +2350,25 @@ path = View; sourceTree = ""; }; + DBAC6486267D0FAC007FE9FD /* Node */ = { + isa = PBXGroup; + children = ( + DBAC6484267D0F9E007FE9FD /* StatusNode.swift */, + DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */, + DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */, + ); + path = Node; + sourceTree = ""; + }; + DBAC6490267DC84F007FE9FD /* DataSource */ = { + isa = PBXGroup; + children = ( + DBAC6487267D388B007FE9FD /* ASTableNode.swift */, + DBAC648E267DC84D007FE9FD /* TableNodeDiffableDataSource.swift */, + ); + path = DataSource; + sourceTree = ""; + }; DBAE3F742615DD63004B8251 /* UserProvider */ = { isa = PBXGroup; children = ( @@ -2510,6 +2551,8 @@ DBB525072611EAC0002F1F29 /* Tabman */, DB6F5E31264E7410009108F4 /* TwitterTextEditor */, DBAEDE5E267A0B1500D25FF5 /* Nuke */, + DBAC6482267D0B21007FE9FD /* DifferenceKit */, + DBAC649D267DFE43007FE9FD /* DiffableDataSources */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -2698,6 +2741,8 @@ DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */, DB6F5E30264E7410009108F4 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, DBAEDE5D267A0B1500D25FF5 /* XCRemoteSwiftPackageReference "Nuke" */, + DBAC6481267D0B21007FE9FD /* XCRemoteSwiftPackageReference "DifferenceKit" */, + DBAC649C267DFE43007FE9FD /* XCRemoteSwiftPackageReference "DiffableDataSources" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -2992,9 +3037,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 */, @@ -3036,6 +3083,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 */, @@ -3056,7 +3104,9 @@ DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.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 */, @@ -3129,6 +3179,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 */, @@ -3262,6 +3313,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 */, @@ -4118,6 +4170,22 @@ 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; + }; + }; DBAEDE5D267A0B1500D25FF5 /* XCRemoteSwiftPackageReference "Nuke" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kean/Nuke.git"; @@ -4201,6 +4269,16 @@ 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; + }; 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 2fb2d9806..50a025853 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,12 +7,12 @@ AppShared.xcscheme_^#shared#^_ orderHint - 14 + 19 CoreDataStack.xcscheme_^#shared#^_ orderHint - 17 + 18 Mastodon - RTL.xcscheme_^#shared#^_ @@ -27,12 +27,12 @@ Mastodon.xcscheme_^#shared#^_ orderHint - 2 + 17 NotificationService.xcscheme_^#shared#^_ orderHint - 16 + 20 SuppressBuildableAutocreation diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1ca83d0d7..f46eff823 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -46,6 +46,24 @@ "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": "KeychainAccess", "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git", 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 c5d0eb19b..5cc2ed198 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.. NSManagedObjectID? in switch item { case .homeTimelineIndex(let objectID, _): return objectID @@ -354,5 +354,30 @@ extension HomeTimelineViewController { 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/HomeTimelineViewController+Provider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift index d735d5843..38d843114 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift @@ -53,7 +53,7 @@ extension HomeTimelineViewController: StatusProvider { } var tableViewDiffableDataSource: UITableViewDiffableDataSource? { - return viewModel.diffableDataSource + return nil } func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 529f2d81c..68afb41e2 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -14,12 +14,13 @@ import CoreDataStack import GameplayKit import MastodonSDK import AlamofireImage +import AsyncDisplayKit #if DEBUG import GDPerformanceView_Swift #endif -final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { +final class HomeTimelineViewController: ASDKViewController, NeedsDependency, MediaPreviewableViewController { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -53,17 +54,18 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate) return barButtonItem }() - - 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 - }() + + 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) @@ -72,7 +74,16 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media }() 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) } @@ -83,13 +94,15 @@ extension HomeTimelineViewController { 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) @@ -98,52 +111,56 @@ extension HomeTimelineViewController { 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(HomeTimelineViewController.composeBarButtonItemPressed(_:)) - - tableView.refreshControl = refreshControl + + node.view.refreshControl = refreshControl refreshControl.addTarget(self, action: #selector(HomeTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged) - - tableView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(tableView) - NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.topAnchor), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - - 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 +// +// 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 - tableView.delegate = self - tableView.prefetchDataSource = self + node.delegate = self viewModel.setupDiffableDataSource( - for: tableView, + tableNode: node, dependency: self, statusTableViewCellDelegate: self, timelineMiddleLoaderTableViewCellDelegate: self ) + +// tableView.delegate = self +// tableView.prefetchDataSource = self + // bind refresh control viewModel.isFetchingLatestTimeline .receive(on: DispatchQueue.main) @@ -157,88 +174,88 @@ extension HomeTimelineViewController { } } .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) + +// 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) - } +// 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) - } - } +// 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) +// 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() - } +// coordinator.animate { _ in +// // do nothing +// } completion: { _ in +// // fix AutoLayout cell height not update after rotate issue +// self.viewModel.cellFrameCache.removeAllObjects() +// self.tableView.reloadData() +// } } } @@ -315,100 +332,75 @@ extension HomeTimelineViewController { return } } - - @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) - } } // MARK: - StatusTableViewControllerAspect -extension HomeTimelineViewController: StatusTableViewControllerAspect { } +//extension HomeTimelineViewController: StatusTableViewControllerAspect { } -extension HomeTimelineViewController: TableViewCellHeightCacheableContainer { - var cellFrameCache: NSCache { return viewModel.cellFrameCache } -} +//extension HomeTimelineViewController: TableViewCellHeightCacheableContainer { +// var cellFrameCache: NSCache { return viewModel.cellFrameCache } +//} // MARK: - UIScrollViewDelegate extension HomeTimelineViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { - aspectScrollViewDidScroll(scrollView) + //aspectScrollViewDidScroll(scrollView) viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) } } -extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer { - typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell - typealias LoadingState = HomeTimelineViewModel.LoadOldestState.Loading - var loadMoreConfigurableTableView: UITableView { return tableView } - var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine } -} +//extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer { +// typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell +// typealias LoadingState = HomeTimelineViewModel.LoadOldestState.Loading +// var loadMoreConfigurableTableView: UITableView { return tableView } +// var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine } +//} // MARK: - UITableViewDelegate -extension HomeTimelineViewController: 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) - } - -} +//extension HomeTimelineViewController: 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 HomeTimelineViewController: UITableViewDataSourcePrefetching { - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - aspectTableView(tableView, prefetchRowsAt: indexPaths) - } -} +//extension HomeTimelineViewController: UITableViewDataSourcePrefetching { +// func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { +// aspectTableView(tableView, prefetchRowsAt: indexPaths) +// } +//} // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { @@ -482,9 +474,9 @@ extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate // MARK: - ScrollViewContainer extension HomeTimelineViewController: ScrollViewContainer { - + var scrollView: UIScrollView { return tableView } - + func scrollToTop(animated: Bool) { if scrollView.contentOffset.y < scrollView.frame.height, viewModel.loadLatestStateMachine.canEnterState(HomeTimelineViewModel.LoadLatestState.Loading.self), @@ -499,10 +491,10 @@ extension HomeTimelineViewController: ScrollViewContainer { } else { let indexPath = IndexPath(row: 0, section: 0) guard viewModel.diffableDataSource?.itemIdentifier(for: indexPath) != nil else { return } - tableView.scrollToRow(at: indexPath, at: .top, animated: true) + node.scrollToRow(at: indexPath, at: .top, animated: true) } } - + } // MARK: - AVPlayerViewControllerDelegate @@ -532,7 +524,7 @@ extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate guard let diffableDataSource = viewModel.diffableDataSource else { return } let indexPath = IndexPath(row: 0, section: 0) guard diffableDataSource.itemIdentifier(for: indexPath) != nil else { return } - tableView.scrollToRow(at: indexPath, at: .top, animated: true) + node.scrollToRow(at: indexPath, at: .top, animated: true) case .offlineButton: // TODO: retry break @@ -568,3 +560,20 @@ extension HomeTimelineViewController: PerformanceMonitorDelegate { } } #endif + +// MARK: - ASTableDelegate +extension HomeTimelineViewController: 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) + } +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index 6f5e66c0e..5667af39f 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -9,35 +9,30 @@ import os.log import UIKit import CoreData import CoreDataStack +import AsyncDisplayKit +import DifferenceKit +import DiffableDataSources extension HomeTimelineViewModel { - + func setupDiffableDataSource( - for tableView: UITableView, + tableNode: ASTableNode, dependency: NeedsDependency, statusTableViewCellDelegate: StatusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate ) { - let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) - .autoconnect() - .share() - .eraseToAnyPublisher() - - diffableDataSource = StatusSection.tableViewDiffableDataSource( - for: tableView, - dependency: dependency, - managedObjectContext: fetchedResultsController.managedObjectContext, - timestampUpdatePublisher: timestampUpdatePublisher, - statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, - threadReplyLoaderTableViewCellDelegate: nil + tableNode.automaticallyAdjustsContentOffset = true + + diffableDataSource = StatusSection.tableNodeDiffableDataSource( + tableNode: tableNode, + managedObjectContext: fetchedResultsController.managedObjectContext ) - -// var snapshot = NSDiffableDataSourceSnapshot() -// snapshot.appendSections([.main]) -// diffableDataSource?.apply(snapshot) + + var snapshot = DiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) } - + } // MARK: - NSFetchedResultsControllerDelegate @@ -49,21 +44,18 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - guard let tableView = self.tableView else { return } - guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return } - + guard let diffableDataSource = self.diffableDataSource else { return } let oldSnapshot = diffableDataSource.snapshot() - + let predicate = fetchedResultsController.fetchRequest.predicate let parentManagedObjectContext = fetchedResultsController.managedObjectContext let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) managedObjectContext.parent = parentManagedObjectContext - + managedObjectContext.perform { var shouldAddBottomLoader = false - + let timelineIndexes: [HomeTimelineIndex] = { let request = HomeTimelineIndex.sortedFetchRequest request.returnsObjectsAsFaults = false @@ -75,25 +67,25 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { 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): @@ -105,30 +97,22 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { break } } // end for - - var newSnapshot = NSDiffableDataSourceSnapshot() + + var newSnapshot = DiffableDataSourceSnapshot() newSnapshot.appendSections([.main]) newSnapshot.appendItems(newTimelineItems, toSection: .main) - + let endSnapshot = CACurrentMediaTime() - - DispatchQueue.main.async { - if shouldAddBottomLoader, !(self.loadoldestStateMachine.currentState is LoadOldestState.NoMore) { - newSnapshot.appendItems([.bottomLoader], toSection: .main) - } - - guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { - diffableDataSource.apply(newSnapshot) - self.isFetchingLatestTimeline.value = false - return - } - - diffableDataSource.apply(newSnapshot, animatingDifferences: false) { - tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) - tableView.contentOffset.y = tableView.contentOffset.y - difference.offset + + 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) } @@ -145,8 +129,8 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { private func calculateReloadSnapshotDifference( navigationBar: UINavigationBar, tableView: UITableView, - oldSnapshot: NSDiffableDataSourceSnapshot, - newSnapshot: NSDiffableDataSourceSnapshot + oldSnapshot: DiffableDataSourceSnapshot, + newSnapshot: DiffableDataSourceSnapshot ) -> Difference? { guard oldSnapshot.numberOfItems != 0 else { return nil } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index fdbbfba9d..11893e33c 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -16,6 +16,7 @@ import GameplayKit import AlamofireImage import DateToolsSwift import ActiveLabel +import AsyncDisplayKit final class HomeTimelineViewModel: NSObject { @@ -29,15 +30,18 @@ final class HomeTimelineViewModel: NSObject { let isFetchingLatestTimeline = CurrentValueSubject(false) let viewDidAppear = PassthroughSubject() let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel - + + weak var tableNode: ASTableNode? weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? - weak var tableView: UITableView? + //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 @@ -67,7 +71,7 @@ final class HomeTimelineViewModel: NSObject { lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) // middle loader let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine - var diffableDataSource: UITableViewDiffableDataSource? + // var diffableDataSource: UITableViewDiffableDataSource? var cellFrameCache = NSCache() @@ -100,12 +104,7 @@ final class HomeTimelineViewModel: NSObject { guard let self = self else { return } self.fetchedResultsController.fetchRequest.predicate = predicate do { - self.diffableDataSource?.defaultRowAnimation = .fade try self.fetchedResultsController.performFetch() - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in - guard let self = self else { return } - self.diffableDataSource?.defaultRowAnimation = .automatic - } } catch { assertionFailure(error.localizedDescription) } diff --git a/Mastodon/Scene/Share/View/Node/StatusNode.swift b/Mastodon/Scene/Share/View/Node/StatusNode.swift new file mode 100644 index 000000000..818711083 --- /dev/null +++ b/Mastodon/Scene/Share/View/Node/StatusNode.swift @@ -0,0 +1,109 @@ +// +// StatusNNode.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-6-19. +// + +import UIKit +import Combine +import AsyncDisplayKit +import CoreDataStack + +final class StatusNode: ASCellNode { + + var disposeBag = Set() + + static let avatarImageSize = CGSize(width: 42, height: 42) + static let avatarImageCornerRadius: CGFloat = 4 + + let avatarImageNode: ASNetworkImageNode = { + let node = ASNetworkImageNode() + node.contentMode = .scaleAspectFill + node.defaultImage = UIImage.placeholder(color: .systemFill) + node.cornerRadius = StatusNode.avatarImageCornerRadius + // node.cornerRoundingType = .precomposited + return node + }() + + let nameTextNode = ASTextNode() + let nameDotTextNode = ASTextNode() + let dateTextNode = ASTextNode() + let usernameTextNode = ASTextNode() + + init(status: Status) { + super.init() + + 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 + let createdAt = (status.reblog ?? status).createdAt + dateTextNode.attributedText = NSAttributedString(string: createdAt.slowedTimeAgoSinceNow, attributes: [ + .foregroundColor: Asset.Colors.Label.secondary.color, + .font: UIFont.systemFont(ofSize: 13, weight: .regular) + ]) +// RunLoop.main.perform { [weak self] in +// guard let self = self else { return } +// AppContext.shared.timestampUpdatePublisher +// .sink { [weak self] _ in +// guard let self = self else { return } +// self.dateTextNode.attributedText = NSAttributedString(string: createdAt.slowedTimeAgoSinceNow, attributes: [ +// .foregroundColor: Asset.Colors.Label.secondary.color, +// .font: UIFont.systemFont(ofSize: 13, weight: .regular) +// ]) +// } +// .store(in: &self.disposeBag) +// } + usernameTextNode.attributedText = NSAttributedString(string: "@" + status.author.acct, attributes: [ + .foregroundColor: Asset.Colors.Label.secondary.color, + .font: UIFont.systemFont(ofSize: 15, weight: .regular) + ]) + } + + 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.children = [ + headerStack + ] + + return verticalStack + } + +} diff --git a/Mastodon/Scene/Share/View/Node/TimelineBottomLoaderNode.swift b/Mastodon/Scene/Share/View/Node/TimelineBottomLoaderNode.swift new file mode 100644 index 000000000..aeff71e4f --- /dev/null +++ b/Mastodon/Scene/Share/View/Node/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/TimelineMiddleLoaderNode.swift b/Mastodon/Scene/Share/View/Node/TimelineMiddleLoaderNode.swift new file mode 100644 index 000000000..33a15dd75 --- /dev/null +++ b/Mastodon/Scene/Share/View/Node/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/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..31a7db5ba 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 @@ -33,6 +34,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { #if DEBUG PerformanceMonitor.shared().start() + // ASDisplayNode.shouldShowRangeDebugOverlay = 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..ce8b41ff8 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 - "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: 464046172607e3a92ad500f8050ee34566a47c73 COCOAPODS: 1.10.1 From 69a7517fde9dd7cc12d2ca216211861422559560 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 21 Jun 2021 00:14:47 +0800 Subject: [PATCH 2/4] feat: add content for StatusNode. Migrate HTML parser from Kanna to Fuzi --- Mastodon.xcodeproj/project.pbxproj | 47 +++++++- .../xcschemes/xcschememanagement.plist | 4 +- .../xcshareddata/swiftpm/Package.resolved | 9 ++ .../MastodonStatusContent+Appearance.swift | 17 +++ .../MastodonStatusContent+ParseResult.swift | 108 +++++++++++++++++ Mastodon/Helper/MastodonStatusContent.swift | 103 +++++----------- .../StatusProvider+StatusNodeDelegate.swift | 16 +++ .../StatusProvider/StatusProvider.swift | 9 ++ .../StatusProvider/StatusProviderFacade.swift | 111 ++++++++++++------ .../HomeTimelineViewController+Provider.swift | 24 ++++ .../HomeTimelineViewController.swift | 9 ++ .../View/Node/ASMetaEditableTextNode.swift | 21 ++++ .../View/Node/{ => Status}/StatusNode.swift | 66 ++++++++++- .../TimelineBottomLoaderNode.swift | 0 .../TimelineMiddleLoaderNode.swift | 0 Mastodon/Supporting Files/AppDelegate.swift | 4 +- 16 files changed, 432 insertions(+), 116 deletions(-) create mode 100644 Mastodon/Helper/MastodonStatusContent+Appearance.swift create mode 100644 Mastodon/Helper/MastodonStatusContent+ParseResult.swift create mode 100644 Mastodon/Protocol/StatusProvider/StatusProvider+StatusNodeDelegate.swift create mode 100644 Mastodon/Scene/Share/View/Node/ASMetaEditableTextNode.swift rename Mastodon/Scene/Share/View/Node/{ => Status}/StatusNode.swift (59%) rename Mastodon/Scene/Share/View/Node/{ => Status}/TimelineBottomLoaderNode.swift (100%) rename Mastodon/Scene/Share/View/Node/{ => Status}/TimelineMiddleLoaderNode.swift (100%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 5201c5409..8fe09fe6d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -183,6 +183,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 */; }; @@ -202,6 +203,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 */; }; @@ -416,6 +420,7 @@ 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 */; }; @@ -765,6 +770,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 = ""; }; @@ -786,6 +792,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 = ""; }; @@ -1083,6 +1092,7 @@ 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 */, @@ -1299,6 +1309,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 */, @@ -1654,6 +1665,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 = ( @@ -2307,6 +2328,8 @@ isa = PBXGroup; children = ( 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */, + DB1EE7AD267F3071000CC337 /* MastodonStatusContent+ParseResult.swift */, + DB1EE7AF267F3088000CC337 /* MastodonStatusContent+Appearance.swift */, DB6F5E2E264E5518009108F4 /* MastodonRegex.swift */, DB35FC2E26130172006193C9 /* MastodonField.swift */, DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */, @@ -2353,9 +2376,8 @@ DBAC6486267D0FAC007FE9FD /* Node */ = { isa = PBXGroup; children = ( - DBAC6484267D0F9E007FE9FD /* StatusNode.swift */, - DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */, - DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */, + DB023296267F0ABE00031745 /* Status */, + DB023294267F0AB800031745 /* ASMetaEditableTextNode.swift */, ); path = Node; sourceTree = ""; @@ -2553,6 +2575,7 @@ DBAEDE5E267A0B1500D25FF5 /* Nuke */, DBAC6482267D0B21007FE9FD /* DifferenceKit */, DBAC649D267DFE43007FE9FD /* DiffableDataSources */, + DBAC64A0267E6D02007FE9FD /* Fuzi */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -2743,6 +2766,7 @@ DBAEDE5D267A0B1500D25FF5 /* XCRemoteSwiftPackageReference "Nuke" */, DBAC6481267D0B21007FE9FD /* XCRemoteSwiftPackageReference "DifferenceKit" */, DBAC649C267DFE43007FE9FD /* XCRemoteSwiftPackageReference "DiffableDataSources" */, + DBAC649F267E6D01007FE9FD /* XCRemoteSwiftPackageReference "Fuzi" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -3210,6 +3234,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 */, @@ -3283,6 +3308,7 @@ 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 */, @@ -3299,6 +3325,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 */, @@ -3353,6 +3380,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 */, @@ -4186,6 +4214,14 @@ 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"; @@ -4279,6 +4315,11 @@ 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 50a025853..d2befef16 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ AppShared.xcscheme_^#shared#^_ orderHint - 19 + 20 CoreDataStack.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 20 + 19 SuppressBuildableAutocreation diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index f46eff823..785af99e3 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -64,6 +64,15 @@ "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", diff --git a/Mastodon/Helper/MastodonStatusContent+Appearance.swift b/Mastodon/Helper/MastodonStatusContent+Appearance.swift new file mode 100644 index 000000000..f627093c6 --- /dev/null +++ b/Mastodon/Helper/MastodonStatusContent+Appearance.swift @@ -0,0 +1,17 @@ +// +// MastodonStatusContent+Appearance.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-6-20. +// + +import UIKit + +extension MastodonStatusContent { + struct Appearance { + let attributes: [NSAttributedString.Key: Any] + let urlAttributes: [NSAttributedString.Key: Any] + let hashtagAttributes: [NSAttributedString.Key: Any] + let mentionAttributes: [NSAttributedString.Key: Any] + } +} diff --git a/Mastodon/Helper/MastodonStatusContent+ParseResult.swift b/Mastodon/Helper/MastodonStatusContent+ParseResult.swift new file mode 100644 index 000000000..f1f02fae1 --- /dev/null +++ b/Mastodon/Helper/MastodonStatusContent+ParseResult.swift @@ -0,0 +1,108 @@ +// +// MastodonStatusContent+ParseResult.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-6-20. +// + +import Foundation +import ActiveLabel + +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: + } + + 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/HomeTimelineViewController+Provider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift index 38d843114..18a96f93d 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift @@ -10,6 +10,7 @@ import UIKit import Combine import CoreData import CoreDataStack +import AsyncDisplayKit // MARK: - StatusProvider extension HomeTimelineViewController: StatusProvider { @@ -83,6 +84,29 @@ extension HomeTimelineViewController: StatusProvider { } 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 + } + } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 68afb41e2..4502c05d1 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -576,4 +576,13 @@ extension HomeTimelineViewController: ASTableDelegate { 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 HomeTimelineViewController: StatusNodeDelegate { } 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/StatusNode.swift b/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift similarity index 59% rename from Mastodon/Scene/Share/View/Node/StatusNode.swift rename to Mastodon/Scene/Share/View/Node/Status/StatusNode.swift index 818711083..d4a3b3ba7 100644 --- a/Mastodon/Scene/Share/View/Node/StatusNode.swift +++ b/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift @@ -9,20 +9,44 @@ import UIKit import Combine import AsyncDisplayKit import CoreDataStack +import ActiveLabel + +protocol StatusNodeDelegate: AnyObject { + func statusNode(_ node: StatusNode, statusContentTextNode: ASMetaEditableTextNode, didSelectActiveEntityType type: ActiveEntityType) +} final class StatusNode: ASCellNode { var disposeBag = Set() + 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 }() @@ -30,6 +54,11 @@ final class StatusNode: ASCellNode { let nameDotTextNode = ASTextNode() let dateTextNode = ASTextNode() let usernameTextNode = ASTextNode() + let statusContentTextNode: ASMetaEditableTextNode = { + let node = ASMetaEditableTextNode() + node.scrollEnabled = false + return node + }() init(status: Status) { super.init() @@ -39,6 +68,7 @@ final class StatusNode: ASCellNode { 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) @@ -65,10 +95,29 @@ final class StatusNode: ASCellNode { // } // .store(in: &self.disposeBag) // } + 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) + } + } + + override func didEnterDisplayState() { + super.didEnterDisplayState() + + statusContentTextNode.textView.isEditable = false + statusContentTextNode.textView.textDragInteraction?.isEnabled = false + statusContentTextNode.textView.linkTextAttributes = [ + .foregroundColor: Asset.Colors.brandBlue.color + ] } override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { @@ -99,11 +148,26 @@ final class StatusNode: ASCellNode { headerStack.children = headerStackChildren let verticalStack = ASStackLayoutSpec.vertical() + verticalStack.spacing = 10 verticalStack.children = [ - headerStack + headerStack, + statusContentTextNode, ] return verticalStack } } + +// 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 + } +} diff --git a/Mastodon/Scene/Share/View/Node/TimelineBottomLoaderNode.swift b/Mastodon/Scene/Share/View/Node/Status/TimelineBottomLoaderNode.swift similarity index 100% rename from Mastodon/Scene/Share/View/Node/TimelineBottomLoaderNode.swift rename to Mastodon/Scene/Share/View/Node/Status/TimelineBottomLoaderNode.swift diff --git a/Mastodon/Scene/Share/View/Node/TimelineMiddleLoaderNode.swift b/Mastodon/Scene/Share/View/Node/Status/TimelineMiddleLoaderNode.swift similarity index 100% rename from Mastodon/Scene/Share/View/Node/TimelineMiddleLoaderNode.swift rename to Mastodon/Scene/Share/View/Node/Status/TimelineMiddleLoaderNode.swift diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 31a7db5ba..71073729e 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -33,8 +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 From 6f8666aaa8931cd76883368721f791b9fed9966b Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 21 Jun 2021 00:26:23 +0800 Subject: [PATCH 3/4] feat: add timestamp updater --- .../Share/View/Node/Status/StatusNode.swift | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift b/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift index d4a3b3ba7..c4ec3e680 100644 --- a/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift +++ b/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift @@ -18,6 +18,8 @@ protocol StatusNodeDelegate: AnyObject { 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) @@ -61,6 +63,7 @@ final class StatusNode: ASCellNode { }() init(status: Status) { + timestamp = (status.reblog ?? status).createdAt super.init() automaticallyManagesSubnodes = true @@ -78,23 +81,10 @@ final class StatusNode: ASCellNode { .font: UIFont.systemFont(ofSize: 13, weight: .regular) ]) // set date - let createdAt = (status.reblog ?? status).createdAt - dateTextNode.attributedText = NSAttributedString(string: createdAt.slowedTimeAgoSinceNow, attributes: [ + dateTextNode.attributedText = NSAttributedString(string: timestamp.slowedTimeAgoSinceNow, attributes: [ .foregroundColor: Asset.Colors.Label.secondary.color, .font: UIFont.systemFont(ofSize: 13, weight: .regular) ]) -// RunLoop.main.perform { [weak self] in -// guard let self = self else { return } -// AppContext.shared.timestampUpdatePublisher -// .sink { [weak self] _ in -// guard let self = self else { return } -// self.dateTextNode.attributedText = NSAttributedString(string: createdAt.slowedTimeAgoSinceNow, attributes: [ -// .foregroundColor: Asset.Colors.Label.secondary.color, -// .font: UIFont.systemFont(ofSize: 13, weight: .regular) -// ]) -// } -// .store(in: &self.disposeBag) -// } usernameTextNode.attributedText = NSAttributedString(string: "@" + status.author.acct, attributes: [ .foregroundColor: Asset.Colors.Label.secondary.color, @@ -113,6 +103,15 @@ final class StatusNode: ASCellNode { 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) + ]) + } + statusContentTextNode.textView.isEditable = false statusContentTextNode.textView.textDragInteraction?.isEnabled = false statusContentTextNode.textView.linkTextAttributes = [ @@ -120,6 +119,11 @@ final class StatusNode: ASCellNode { ] } + override func didExitVisibleState() { + super.didExitVisibleState() + timestampSubscription = nil + } + override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { let headerStack = ASStackLayoutSpec.horizontal() headerStack.alignItems = .center From 1156af3d4c5215f475283d90566fc78f976da95d Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 21 Jun 2021 01:38:13 +0800 Subject: [PATCH 4/4] feat: add multiplex image nodes to StatusNode with progress loading supports --- .../Share/View/Node/Status/StatusNode.swift | 65 ++++++++++++++++++- .../ViewModel/MosaicImageViewModel.swift | 9 ++- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift b/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift index c4ec3e680..9ddbcf87b 100644 --- a/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift +++ b/Mastodon/Scene/Share/View/Node/Status/StatusNode.swift @@ -10,6 +10,7 @@ import Combine import AsyncDisplayKit import CoreDataStack import ActiveLabel +import func AVFoundation.AVMakeRect protocol StatusNodeDelegate: AnyObject { func statusNode(_ node: StatusNode, statusContentTextNode: ASMetaEditableTextNode, didSelectActiveEntityType type: ActiveEntityType) @@ -20,6 +21,7 @@ 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) @@ -51,7 +53,6 @@ final class StatusNode: ASCellNode { // node.shouldRenderProgressImages = true return node }() - let nameTextNode = ASTextNode() let nameDotTextNode = ASTextNode() let dateTextNode = ASTextNode() @@ -62,10 +63,29 @@ final class StatusNode: ASCellNode { 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() { @@ -98,6 +118,10 @@ final class StatusNode: ASCellNode { ) { statusContentTextNode.attributedText = parseResult.trimmedAttributedString(appearance: StatusNode.statusContentAppearance) } + + for imageNode in mediaMultiplexImageNodes { + imageNode.dataSource = self + } } override func didEnterDisplayState() { @@ -112,6 +136,7 @@ final class StatusNode: ASCellNode { ]) } + // 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 = [ @@ -153,16 +178,34 @@ final class StatusNode: ASCellNode { let verticalStack = ASStackLayoutSpec.vertical() verticalStack.spacing = 10 - verticalStack.children = [ + 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 { @@ -175,3 +218,21 @@ extension StatusNode: ASMetaEditableTextNodeDelegate { 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/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?