diff --git a/Localization/app.json b/Localization/app.json index f29c588c..e0359caa 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -89,7 +89,8 @@ "timeline": { "loader": { "load_missing_posts": "Load missing posts", - "loading_missing_posts": "Loading missing posts..." + "loading_missing_posts": "Loading missing posts...", + "show_more_replies": "Show more replies" }, "header": { "no_status_found": "No Status Found", @@ -198,7 +199,7 @@ }, "confirm_email": { "title": "One last thing.", - "subtitle": "We just sent an email to %@,\ntap the link to confirm your account.", + "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", "button": { "open_email_app": "Open Email App", "dont_receive_email": "I never got an email" @@ -312,6 +313,18 @@ }, "favorite": { "title": "Your Favorites" + }, + "thread": { + "back_title": "Post", + "title": "Post from %s", + "reblog": { + "single": "%s reblog", + "multiple": "%s reblogs" + }, + "favorite": { + "single": "%s favorite", + "multiple": "%s favorites" + } } } } \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 01b40a9c..6076f2ee 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -135,6 +135,8 @@ DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; }; DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; }; DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; + DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; }; + DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; }; DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; }; DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; @@ -254,6 +256,15 @@ DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */; }; DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */; }; + DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */; }; + DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */; }; + DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */; }; + DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */; }; + DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */; }; + DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */; }; + DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */; }; + DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F24262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift */; }; + DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */; }; DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; }; DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; }; DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; }; @@ -297,6 +308,7 @@ DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525632612C988002F1F29 /* MeProfileViewModel.swift */; }; DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */; }; DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */; }; + DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB9759B262462E1004620BD /* ThreadMetaView.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; }; DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */; }; @@ -506,6 +518,8 @@ DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = ""; }; DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = ""; }; DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; + DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = ""; }; + DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = ""; }; DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -633,6 +647,15 @@ DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = ""; }; DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerEmptyStateView.swift; sourceTree = ""; }; DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionTableViewCell.swift; sourceTree = ""; }; + DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewController.swift; sourceTree = ""; }; + DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewModel.swift; sourceTree = ""; }; + DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedThreadViewModel.swift; sourceTree = ""; }; + DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteThreadViewModel.swift; sourceTree = ""; }; + DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+LoadThreadState.swift"; sourceTree = ""; }; + DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Thread.swift"; sourceTree = ""; }; + DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+Diffable.swift"; sourceTree = ""; }; + DB938F24262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewController+StatusProvider.swift"; sourceTree = ""; }; + DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTopLoaderTableViewCell.swift; sourceTree = ""; }; DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = ""; }; DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = ""; }; DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = ""; }; @@ -675,6 +698,7 @@ DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = ""; }; DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardView.swift; sourceTree = ""; }; DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardMeterView.swift; sourceTree = ""; }; + DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = ""; }; DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPublishService.swift; sourceTree = ""; }; @@ -847,6 +871,7 @@ DB87D44A2609C11900D12C0D /* PollOptionView.swift */, DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */, 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */, + DBB9759B262462E1004620BD /* ThreadMetaView.swift */, ); path = Content; sourceTree = ""; @@ -1051,9 +1076,11 @@ children = ( 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */, 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, + DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */, 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */, DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */, + DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */, DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */, ); path = TableviewCell; @@ -1281,6 +1308,7 @@ DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */, + DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */, DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, DB9A488326034BD7008B817C /* APIService+Status.swift */, DB9A488F26035963008B817C /* APIService+Media.swift */, @@ -1523,6 +1551,7 @@ DB9D6BFD25E4F57B0051B173 /* Notification */, DB9D6C0825E4F5A60051B173 /* Profile */, DB789A1025F9F29B0071ACA0 /* Compose */, + DB938EEB2623F52600E5B6C1 /* Thread */, ); path = Scene; sourceTree = ""; @@ -1563,6 +1592,20 @@ path = Extension; sourceTree = ""; }; + DB938EEB2623F52600E5B6C1 /* Thread */ = { + isa = PBXGroup; + children = ( + DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */, + DB938F24262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift */, + DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */, + DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */, + DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */, + DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */, + DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */, + ); + path = Thread; + sourceTree = ""; + }; DB98335F25C93B0400AD9700 /* Recovered References */ = { isa = PBXGroup; children = ( @@ -1665,6 +1708,7 @@ children = ( DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */, DB59F11725EFA35B001F1DAB /* StripProgressView.swift */, + DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */, ); path = Control; sourceTree = ""; @@ -2164,6 +2208,7 @@ 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, + DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */, DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */, @@ -2172,6 +2217,7 @@ 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, + DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */, 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */, DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */, DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, @@ -2184,6 +2230,7 @@ DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */, + DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, @@ -2203,6 +2250,7 @@ DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */, + DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */, @@ -2226,6 +2274,7 @@ DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, + DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, @@ -2291,8 +2340,10 @@ DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */, + DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */, + DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, @@ -2310,11 +2361,13 @@ DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, 2D34D9CB261489930081BFC0 /* SearchViewController+RecomendView.swift in Sources */, + DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */, DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, + DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */, @@ -2359,6 +2412,7 @@ 0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */, 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, + DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, @@ -2379,7 +2433,9 @@ 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, + DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, + DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */, DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 36d42745..8874a69e 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -51,6 +51,9 @@ extension SceneCoordinator { // compose case compose(viewModel: ComposeViewModel) + // thread + case thread(viewModel: ThreadViewModel) + // Hashtag Timeline case hashtagTimeline(viewModel: HashtagTimelineViewModel) @@ -226,6 +229,10 @@ private extension SceneCoordinator { let _viewController = ComposeViewController() _viewController.viewModel = viewModel viewController = _viewController + case .thread(let viewModel): + let _viewController = ThreadViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .hashtagTimeline(let viewModel): let _viewController = HashtagTimelineViewController() _viewController.viewModel = viewModel diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index 9f82f6ca..da345520 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -14,6 +14,12 @@ import MastodonSDK enum Item { // timeline case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusAttribute) + + // thread + case root(statusObjectID: NSManagedObjectID, attribute: StatusAttribute) + case reply(statusObjectID: NSManagedObjectID, attribute: StatusAttribute) + case leaf(statusObjectID: NSManagedObjectID, attribute: StatusAttribute) + case leafBottomLoader(statusObjectID: NSManagedObjectID) // normal list case status(objectID: NSManagedObjectID, attribute: StatusAttribute) @@ -21,6 +27,7 @@ enum Item { // loader case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID) case publicMiddleLoader(statusID: String) + case topLoader case bottomLoader case emptyStateHeader(attribute: EmptyStateHeaderAttribute) @@ -35,13 +42,16 @@ extension Item { class StatusAttribute: StatusContentWarningAttribute { var isStatusTextSensitive: Bool? var isStatusSensitive: Bool? + var isSeparatorLineHidden: Bool init( isStatusTextSensitive: Bool? = nil, - isStatusSensitive: Bool? = nil + isStatusSensitive: Bool? = nil, + isSeparatorLineHidden: Bool = false ) { self.isStatusTextSensitive = isStatusTextSensitive self.isStatusSensitive = isStatusSensitive + self.isSeparatorLineHidden = isSeparatorLineHidden } // delay attribute init @@ -59,6 +69,23 @@ extension Item { } } +// class LeafAttribute { +// let identifier = UUID() +// let statusID: Status.ID +// var level: Int = 0 +// var hasReply: Bool = true +// +// init( +// statusID: Status.ID, +// level: Int, +// hasReply: Bool = true +// ) { +// self.statusID = statusID +// self.level = level +// self.hasReply = hasReply +// } +// } + class EmptyStateHeaderAttribute: Hashable { let id = UUID() let reason: Reason @@ -99,12 +126,22 @@ extension Item: Equatable { switch (lhs, rhs) { case (.homeTimelineIndex(let objectIDLeft, _), .homeTimelineIndex(let objectIDRight, _)): return objectIDLeft == objectIDRight + case (.root(let objectIDLeft, _), .root(let objectIDRight, _)): + return objectIDLeft == objectIDRight + case (.reply(let objectIDLeft, _), .reply(let objectIDRight, _)): + return objectIDLeft == objectIDRight + case (.leaf(let objectIDLeft, _), .leaf(let objectIDRight, _)): + return objectIDLeft == objectIDRight + case (.leafBottomLoader(let objectIDLeft), .leafBottomLoader(let objectIDRight)): + return objectIDLeft == objectIDRight case (.status(let objectIDLeft, _), .status(let objectIDRight, _)): return objectIDLeft == objectIDRight case (.homeMiddleLoader(let upperLeft), .homeMiddleLoader(let upperRight)): return upperLeft == upperRight case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)): return upperLeft == upperRight + case (.topLoader, .topLoader): + return true case (.bottomLoader, .bottomLoader): return true case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)): @@ -120,6 +157,14 @@ extension Item: Hashable { switch self { case .homeTimelineIndex(let objectID, _): hasher.combine(objectID) + case .root(let objectID, _): + hasher.combine(objectID) + case .reply(let objectID, _): + hasher.combine(objectID) + case .leaf(let objectID, _): + hasher.combine(objectID) + case .leafBottomLoader(let objectID): + hasher.combine(objectID) case .status(let objectID, _): hasher.combine(objectID) case .homeMiddleLoader(upperTimelineIndexAnchorObjectID: let upper): @@ -128,6 +173,8 @@ extension Item: Hashable { case .publicMiddleLoader(let upper): hasher.combine(String(describing: Item.publicMiddleLoader.self)) hasher.combine(upper) + case .topLoader: + hasher.combine(String(describing: Item.topLoader.self)) case .bottomLoader: hasher.combine(String(describing: Item.bottomLoader.self)) case .emptyStateHeader(let attribute): diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index fe720e0f..d0f93921 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -22,9 +22,16 @@ extension StatusSection { managedObjectContext: NSManagedObjectContext, timestampUpdatePublisher: AnyPublisher, statusTableViewCellDelegate: StatusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? + timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?, + threadReplyLoaderTableViewCellDelegate: ThreadReplyLoaderTableViewCellDelegate? ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [weak statusTableViewCellDelegate, weak timelineMiddleLoaderTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in + UITableViewDiffableDataSource(tableView: tableView) { [ + weak dependency, + weak statusTableViewCellDelegate, + weak timelineMiddleLoaderTableViewCellDelegate, + weak threadReplyLoaderTableViewCellDelegate + ] tableView, indexPath, item -> UITableViewCell? in + guard let dependency = dependency else { return UITableViewCell() } guard let statusTableViewCellDelegate = statusTableViewCellDelegate else { return UITableViewCell() } switch item { @@ -46,7 +53,10 @@ extension StatusSection { } cell.delegate = statusTableViewCellDelegate return cell - case .status(let objectID, let attribute): + case .status(let objectID, let attribute), + .root(let objectID, let attribute), + .reply(let objectID, let attribute), + .leaf(let objectID, let attribute): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" @@ -62,8 +72,30 @@ extension StatusSection { requestUserID: requestUserID, statusItemAttribute: attribute ) + + switch item { + case .root: + StatusSection.configureThreadMeta(cell: cell, status: status) + ManagedObjectObserver.observe(object: status.reblog ?? status) + .receive(on: DispatchQueue.main) + .sink { _ in + // do nothing + } receiveValue: { change in + guard case .update(let object) = change.changeType, + let status = object as? Status else { return } + StatusSection.configureThreadMeta(cell: cell, status: status) + } + .store(in: &cell.disposeBag) + default: + break + } } cell.delegate = statusTableViewCellDelegate + + return cell + case .leafBottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ThreadReplyLoaderTableViewCell.self), for: indexPath) as! ThreadReplyLoaderTableViewCell + cell.delegate = threadReplyLoaderTableViewCellDelegate return cell case .publicMiddleLoader(let upperTimelineStatusID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell @@ -75,6 +107,10 @@ extension StatusSection { cell.delegate = timelineMiddleLoaderTableViewCellDelegate timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: nil, timelineIndexobjectID: upperTimelineIndexObjectID) return cell + case .topLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell + cell.startAnimating() + return cell case .bottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell cell.startAnimating() @@ -288,6 +324,9 @@ extension StatusSection { // toolbar StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID) + // separator line + cell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden + // set date let createdAt = (status.reblog ?? status).createdAt cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow @@ -312,6 +351,41 @@ extension StatusSection { } .store(in: &cell.disposeBag) } + + static func configureThreadMeta( + cell: StatusTableViewCell, + status: Status + ) { + cell.selectionStyle = .none + cell.threadMetaView.dateLabel.text = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: status.createdAt) + }() + let reblogCountTitle: String = { + let count = status.reblogsCount.intValue + if count > 1 { + return L10n.Scene.Thread.Reblog.multiple(String(count)) + } else { + return L10n.Scene.Thread.Reblog.single(String(count)) + } + }() + cell.threadMetaView.reblogButton.setTitle(reblogCountTitle, for: .normal) + + let favoriteCountTitle: String = { + let count = status.favouritesCount.intValue + if count > 1 { + return L10n.Scene.Thread.Favorite.multiple(String(count)) + } else { + return L10n.Scene.Thread.Favorite.single(String(count)) + } + }() + cell.threadMetaView.favoriteButton.setTitle(favoriteCountTitle, for: .normal) + + cell.threadMetaView.isHidden = false + } + static func configureHeader( cell: StatusTableViewCell, @@ -325,10 +399,13 @@ extension StatusSection { let name = author.displayName.isEmpty ? author.username : author.displayName return L10n.Common.Controls.Status.userReblogged(name) }() - } else if let replyTo = status.replyTo { + } else if status.inReplyToID != nil { cell.statusView.headerContainerStackView.isHidden = false cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) cell.statusView.headerInfoLabel.text = { + guard let replyTo = status.replyTo else { + return L10n.Common.Controls.Status.userRepliedTo("-") + } let author = replyTo.author let name = author.displayName.isEmpty ? author.username : author.displayName return L10n.Common.Controls.Status.userRepliedTo(name) diff --git a/Mastodon/Extension/CGImage.swift b/Mastodon/Extension/CGImage.swift index 252f1289..cced4abe 100644 --- a/Mastodon/Extension/CGImage.swift +++ b/Mastodon/Extension/CGImage.swift @@ -26,7 +26,7 @@ extension CGImage { let pointer = CFDataGetBytePtr(data) else { return nil } let length = CFDataGetLength(data) - guard length > 0 else { return nil} + guard length > 0 else { return nil } var luma: CGFloat = 0.0 for i in stride(from: 0, to: length, by: 4) { diff --git a/Mastodon/Extension/UIBarButtonItem.swift b/Mastodon/Extension/UIBarButtonItem.swift index 8a0630f0..cf1f84e9 100644 --- a/Mastodon/Extension/UIBarButtonItem.swift +++ b/Mastodon/Extension/UIBarButtonItem.swift @@ -17,4 +17,3 @@ extension UIBarButtonItem { } } - diff --git a/Mastodon/Extension/UIImage.swift b/Mastodon/Extension/UIImage.swift index 072a3d4d..35766c0b 100644 --- a/Mastodon/Extension/UIImage.swift +++ b/Mastodon/Extension/UIImage.swift @@ -59,7 +59,7 @@ extension UIImage { } } -public extension UIImage { +extension UIImage { func withRoundedCorners(radius: CGFloat? = nil) -> UIImage? { let maxRadius = min(size.width, size.height) / 2 let cornerRadius: CGFloat = { @@ -75,3 +75,18 @@ public extension UIImage { } } } + +extension UIImage { + static func adaptiveUserInterfaceStyleImage(lightImage: UIImage, darkImage: UIImage) -> UIImage { + let imageAsset = UIImageAsset() + imageAsset.register(lightImage, with: UITraitCollection(traitsFrom: [ + UITraitCollection(displayScale: 1.0), + UITraitCollection(userInterfaceStyle: .light) + ])) + imageAsset.register(darkImage, with: UITraitCollection(traitsFrom: [ + UITraitCollection(displayScale: 1.0), + UITraitCollection(userInterfaceStyle: .dark) + ])) + return imageAsset.image(with: UITraitCollection.current) + } +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index da27f7a6..4ed28165 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -203,6 +203,8 @@ internal enum L10n { internal static let loadingMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadingMissingPosts") /// Load missing posts internal static let loadMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadMissingPosts") + /// Show more replies + internal static let showMoreReplies = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.ShowMoreReplies") } } } @@ -567,6 +569,34 @@ internal enum L10n { internal static let confirm = L10n.tr("Localizable", "Scene.ServerRules.Button.Confirm") } } + internal enum Thread { + /// Post + internal static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle") + /// Post from %@ + internal static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Thread.Title", String(describing: p1)) + } + internal enum Favorite { + /// %@ favorites + internal static func multiple(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Thread.Favorite.Multiple", String(describing: p1)) + } + /// %@ favorite + internal static func single(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Thread.Favorite.Single", String(describing: p1)) + } + } + internal enum Reblog { + /// %@ reblogs + internal static func multiple(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Thread.Reblog.Multiple", String(describing: p1)) + } + /// %@ reblog + internal static func single(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Thread.Reblog.Single", String(describing: p1)) + } + } + } internal enum Welcome { /// Social networking\nback in your hands. internal static let slogan = L10n.tr("Localizable", "Scene.Welcome.Slogan") diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index f8c99c13..2983a6f9 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -46,9 +46,11 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard let item = item(for: cell, indexPath: nil) else { return } switch item { - case .homeTimelineIndex(_, let attribute): - attribute.isStatusTextSensitive = false - case .status(_, let attribute): + case .homeTimelineIndex(_, let attribute), + .status(_, let attribute), + .root(_, let attribute), + .reply(_, let attribute), + .leaf(_, let attribute): attribute.isStatusTextSensitive = false default: return @@ -81,9 +83,11 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard let item = item(for: cell, indexPath: nil) else { return } switch item { - case .homeTimelineIndex(_, let attribute): - attribute.isStatusSensitive = false - case .status(_, let attribute): + case .homeTimelineIndex(_, let attribute), + .status(_, let attribute), + .root(_, let attribute), + .reply(_, let attribute), + .leaf(_, let attribute): attribute.isStatusSensitive = false default: return diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index 32915baf..cd6cbf58 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -12,9 +12,6 @@ import os.log import UIKit extension StatusTableViewCellDelegate where Self: StatusProvider { - // TODO: - // func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - // } func handleTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { // update poll when status appear @@ -102,6 +99,11 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } .store(in: &disposeBag) } + + func handleTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + StatusProviderFacade.coordinateToStatusThreadScene(for: .primary, provider: self, indexPath: indexPath) + } + } extension StatusTableViewCellDelegate where Self: StatusProvider {} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift index e16343ee..8e27a220 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift @@ -13,7 +13,7 @@ import CoreDataStack protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController { // async func status() -> Future - func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future + func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future func status(for cell: UICollectionViewCell) -> Future // sync diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index abdc2790..6db861ec 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -60,6 +60,54 @@ extension StatusProviderFacade { } .store(in: &provider.disposeBag) } + +} + +extension StatusProviderFacade { + + static func coordinateToStatusThreadScene(for target: Target, provider: StatusProvider, indexPath: IndexPath) { + _coordinateToStatusThreadScene( + for: target, + provider: provider, + status: provider.status(for: nil, indexPath: indexPath) + ) + } + + static func coordinateToStatusThreadScene(for target: Target, provider: StatusProvider, cell: UITableViewCell) { + _coordinateToStatusThreadScene( + for: target, + provider: provider, + status: provider.status(for: cell, indexPath: nil) + ) + } + + private static func _coordinateToStatusThreadScene(for target: Target, provider: StatusProvider, status: Future) { + status + .sink { [weak provider] status in + guard let provider = provider else { return } + let _status: Status? = { + switch target { + case .primary: return status?.reblog ?? status // original status + case .secondary: return status // reblog or status + } + }() + guard let status = _status else { return } + + let threadViewModel = CachedThreadViewModel(context: provider.context, status: status) + DispatchQueue.main.async { + if provider.navigationController == nil { + let from = provider.presentingViewController ?? provider + provider.dismiss(animated: true) { + provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: from, transition: .show) + } + } else { + provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: provider, transition: .show) + } + } + } + .store(in: &provider.disposeBag) + } + } extension StatusProviderFacade { @@ -339,8 +387,8 @@ extension StatusProviderFacade { extension StatusProviderFacade { enum Target { - case primary // original - case secondary // attachment reblog or reply + case primary // original status + case secondary // wrapper status or reply (when needs. e.g tap header of status view) } } diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift index ecd8291f..77b1e17b 100644 --- a/Mastodon/Protocol/StatusTableViewControllerAspect.swift +++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift @@ -9,10 +9,12 @@ import UIKit import AVKit // Check List Last Updated +// - HomeViewController: 2021/4/13 // - FavoriteViewController: 2021/4/8 // - HashtagTimelineViewController: 2021/4/8 -// - UserTimelineViewController: 2021/4/8 -// * StatusTableViewControllerAspect: 2021/4/7 +// - UserTimelineViewController: 2021/4/13 +// - ThreadViewController: 2021/4/13 +// * StatusTableViewControllerAspect: 2021/4/12 // (Fake) Aspect protocol to group common protocol extension implementations // Needs update related view controller when aspect interface changes @@ -69,7 +71,7 @@ extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegat } } -// [B4] StatusTableViewControllerAspect.aspectTableView(_:didEndDisplaying:forRowAt:) +// [B4] aspectTableView(_:didEndDisplaying:forRowAt:) extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { /// [Media] hook to notify video service func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { @@ -93,6 +95,14 @@ extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegat } } +// [B5] aspectTableView(_:didSelectRowAt:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + /// [UI] hook to coordinator to thread + func aspectTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + handleTableView(tableView, didSelectRowAt: indexPath) + } +} + // MARK: - UITableViewDataSourcePrefetching [C] // [C1] aspectTableView(:prefetchRowsAt) diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json index 55f84c26..3338422a 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2E", - "green" : "0x2C", - "red" : "0x2C" + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json index 55f84c26..6b372a19 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xFE", - "green" : "0xFF", - "red" : "0xFE" + "blue" : "254", + "green" : "255", + "red" : "254" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json index 6bce2b69..6d9833e9 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x00", - "green" : "0x00", - "red" : "0x00" + "blue" : "46", + "green" : "44", + "red" : "44" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json index d8f32572..5da572b1 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xFF", - "green" : "0xFF", - "red" : "0xFF" + "blue" : "0xE8", + "green" : "0xE1", + "red" : "0xD9" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2B", - "green" : "0x23", - "red" : "0x1F" + "blue" : "0x3C", + "green" : "0x3A", + "red" : "0x3A" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json index d4705004..5da572b1 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2B", - "green" : "0x23", - "red" : "0x1F" + "blue" : "0x3C", + "green" : "0x3A", + "red" : "0x3A" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index e94101cc..6914b608 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -68,6 +68,7 @@ Your account looks like this to them."; "Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@'s account has been suspended."; "Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts"; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts..."; +"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; "Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can't be @@ -181,5 +182,11 @@ any server."; "Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; "Scene.ServerRules.TermsOfService" = "terms of service"; "Scene.ServerRules.Title" = "Some ground rules."; +"Scene.Thread.BackTitle" = "Post"; +"Scene.Thread.Favorite.Multiple" = "%@ favorites"; +"Scene.Thread.Favorite.Single" = "%@ favorite"; +"Scene.Thread.Reblog.Multiple" = "%@ reblogs"; +"Scene.Thread.Reblog.Single" = "%@ reblog"; +"Scene.Thread.Title" = "Post from %@"; "Scene.Welcome.Slogan" = "Social networking back in your hands."; \ No newline at end of file diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift index 23068b7b..191ad374 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift @@ -18,14 +18,14 @@ extension HashtagTimelineViewController: StatusProvider { return Future { promise in promise(.success(nil)) } } - func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() promise(.success(nil)) return } - guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), let item = diffableDataSource.itemIdentifier(for: indexPath) else { promise(.success(nil)) return diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index 26f32a33..ed7b3a84 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -28,7 +28,8 @@ extension HashtagTimelineViewModel { managedObjectContext: context.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate + timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, + threadReplyLoaderTableViewCellDelegate: nil ) } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 0c43af79..401e4fc1 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -33,6 +33,10 @@ extension HomeTimelineViewController { guard let self = self else { return } self.showProfileAction(action) }, + UIAction(title: "Show Thread", image: UIImage(systemName: "bubble.left.and.bubble.right"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showThreadAction(action) + }, UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in guard let self = self else { return } self.signOutAction(action) @@ -304,5 +308,20 @@ extension HomeTimelineViewController { coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) } + @objc private func showThreadAction(_ sender: UIAction) { + let alertController = UIAlertController(title: "Enter Status ID", message: nil, preferredStyle: .alert) + alertController.addTextField() + let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in + guard let self = self else { return } + guard let textField = alertController?.textFields?.first else { return } + let threadViewModel = RemoteThreadViewModel(context: self.context, statusID: textField.text ?? "") + self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: self, transition: .show) + } + alertController.addAction(showAction) + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + } + } #endif diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift index 9e191530..aea931a6 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift @@ -18,14 +18,14 @@ extension HomeTimelineViewController: StatusProvider { return Future { promise in promise(.success(nil)) } } - func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() promise(.success(nil)) return } - guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), let item = diffableDataSource.itemIdentifier(for: indexPath) else { promise(.success(nil)) return diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 1f3dea81..f4248ad3 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -179,6 +179,8 @@ extension HomeTimelineViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + aspectViewWillAppear(animated) + // needs trigger manually after onboarding dismiss setNeedsStatusBarAppearanceUpdate() } @@ -198,8 +200,8 @@ extension HomeTimelineViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - context.videoPlaybackService.viewDidDisappear(from: self) - context.audioPlaybackService.viewDidDisappear(from: self) + + aspectViewDidDisappear(animated) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -262,11 +264,19 @@ extension HomeTimelineViewController { } +// MARK: - StatusTableViewControllerAspect +extension HomeTimelineViewController: StatusTableViewControllerAspect { } + +extension HomeTimelineViewController: TableViewCellHeightCacheableContainer { + var cellFrameCache: NSCache { return viewModel.cellFrameCache } +} + // MARK: - UIScrollViewDelegate extension HomeTimelineViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { - handleScrollViewDidScroll(scrollView) - self.viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) + + aspectScrollViewDidScroll(scrollView) + viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) } } @@ -281,32 +291,26 @@ extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer { extension HomeTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - return 200 - // TODO: - // guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } - // guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } - // - // guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { - // return 200 - // } - // // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) - // - // return ceil(frame.height) + aspectTableView(tableView, estimatedHeightForRowAt: indexPath) } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) + aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) } func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) } } // MARK: - UITableViewDataSourcePrefetching extension HomeTimelineViewController: UITableViewDataSourcePrefetching { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - handleTableView(tableView, prefetchRowsAt: indexPaths) + aspectTableView(tableView, prefetchRowsAt: indexPaths) } } @@ -317,7 +321,6 @@ extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControl } } - // MARK: - TimelineMiddleLoaderTableViewCellDelegate extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index 5f16a18e..6f5e66c0 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -29,7 +29,8 @@ extension HomeTimelineViewModel { managedObjectContext: fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate + timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, + threadReplyLoaderTableViewCellDelegate: nil ) // var snapshot = NSDiffableDataSourceSnapshot() @@ -88,6 +89,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { 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)) @@ -96,6 +98,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { switch (isLast, timelineIndex.hasMore) { case (false, true): newTimelineItems.append(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: timelineIndex.objectID)) + attribute.isSeparatorLineHidden = true case (true, true): shouldAddBottomLoader = true default: diff --git a/Mastodon/Scene/Profile/CachedProfileViewModel.swift b/Mastodon/Scene/Profile/CachedProfileViewModel.swift index 0e5823d0..083724be 100644 --- a/Mastodon/Scene/Profile/CachedProfileViewModel.swift +++ b/Mastodon/Scene/Profile/CachedProfileViewModel.swift @@ -10,8 +10,8 @@ import CoreDataStack final class CachedProfileViewModel: ProfileViewModel { - convenience init(context: AppContext, mastodonUser: MastodonUser) { - self.init(context: context, optionalMastodonUser: mastodonUser) + init(context: AppContext, mastodonUser: MastodonUser) { + super.init(context: context, optionalMastodonUser: mastodonUser) } } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift index 2dadc854..68adc1e3 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift @@ -18,14 +18,14 @@ extension FavoriteViewController: StatusProvider { return Future { promise in promise(.success(nil)) } } - func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() promise(.success(nil)) return } - guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), let item = diffableDataSource.itemIdentifier(for: indexPath) else { promise(.success(nil)) return diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift index e64df2c9..85928e85 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift @@ -25,7 +25,8 @@ extension FavoriteViewModel { managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: nil + timelineMiddleLoaderTableViewCellDelegate: nil, + threadReplyLoaderTableViewCellDelegate: nil ) // set empty section to make update animation top-to-bottom style diff --git a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift index c480e6fc..153f5099 100644 --- a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift +++ b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift @@ -12,8 +12,8 @@ import MastodonSDK final class RemoteProfileViewModel: ProfileViewModel { - convenience init(context: AppContext, userID: Mastodon.Entity.Account.ID) { - self.init(context: context, optionalMastodonUser: nil) + init(context: AppContext, userID: Mastodon.Entity.Account.ID) { + super.init(context: context, optionalMastodonUser: nil) guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return @@ -47,8 +47,6 @@ final class RemoteProfileViewModel: ProfileViewModel { self.mastodonUser.value = mastodonUser } .store(in: &disposeBag) - } - } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift index 1ea16440..4fc85781 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift @@ -18,14 +18,14 @@ extension UserTimelineViewController: StatusProvider { return Future { promise in promise(.success(nil)) } } - func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() promise(.success(nil)) return } - guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), let item = diffableDataSource.itemIdentifier(for: indexPath) else { promise(.success(nil)) return diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index e8e71ccf..88a98b58 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -124,6 +124,10 @@ extension UserTimelineViewController: UITableViewDelegate { aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift index 1a09e1b3..8e6f1314 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -25,7 +25,8 @@ extension UserTimelineViewModel { managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: nil + timelineMiddleLoaderTableViewCellDelegate: nil, + threadReplyLoaderTableViewCellDelegate: nil ) // set empty section to make update animation top-to-bottom style diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift index a92b8f37..04fc526a 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift @@ -19,14 +19,14 @@ extension PublicTimelineViewController: StatusProvider { return Future { promise in promise(.success(nil)) } } - func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() promise(.success(nil)) return } - guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), let item = diffableDataSource.itemIdentifier(for: indexPath) else { promise(.success(nil)) return diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index ce0e8b19..3ca407ca 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -28,7 +28,8 @@ extension PublicTimelineViewModel { managedObjectContext: fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate + timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, + threadReplyLoaderTableViewCellDelegate: nil ) items.value = [] stateMachine.enter(PublicTimelineViewModel.State.Loading.self) diff --git a/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift new file mode 100644 index 00000000..16d1b04a --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift @@ -0,0 +1,89 @@ +// +// ThreadMetaView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import UIKit + +final class ThreadMetaView: UIView { + + let dateLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + label.text = "Date" + return label + }() + + let reblogButton: UIButton = { + let button = UIButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) + button.setTitle("0 reblog", for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color.withAlphaComponent(0.5), for: .highlighted) + return button + }() + + let favoriteButton: UIButton = { + let button = UIButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) + button.setTitle("0 favorite", for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color.withAlphaComponent(0.5), for: .highlighted) + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ThreadMetaView { + private func _init() { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 20 + + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor, constant: 8), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomAnchor.constraint(equalTo: stackView.bottomAnchor, constant: 12), + ]) + + stackView.addArrangedSubview(dateLabel) + stackView.addArrangedSubview(reblogButton) + stackView.addArrangedSubview(favoriteButton) + + dateLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + reblogButton.setContentHuggingPriority(.required - 2, for: .horizontal) + favoriteButton.setContentHuggingPriority(.required - 1, for: .horizontal) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ThreadMetaView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + ThreadMetaView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/Control/AdaptiveUserInterfaceStyleBarButtonItem.swift b/Mastodon/Scene/Share/View/Control/AdaptiveUserInterfaceStyleBarButtonItem.swift new file mode 100644 index 00000000..e801d175 --- /dev/null +++ b/Mastodon/Scene/Share/View/Control/AdaptiveUserInterfaceStyleBarButtonItem.swift @@ -0,0 +1,76 @@ +// +// AdaptiveUserInterfaceStyleBarButtonItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-13. +// + +import UIKit + +final class AdaptiveUserInterfaceStyleBarButtonItem: UIBarButtonItem { + + let button = AdaptiveCustomButton() + + init(lightImage: UIImage, darkImage: UIImage) { + super.init() + button.setImage(light: lightImage, dark: darkImage) + self.customView = button + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + +} + +extension AdaptiveUserInterfaceStyleBarButtonItem { + class AdaptiveCustomButton: UIButton { + + var lightImage: UIImage? + var darkImage: UIImage? + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + private func _init() { + adjustsImageWhenHighlighted = false + } + + override var isHighlighted: Bool { + didSet { + alpha = isHighlighted ? 0.6 : 1 + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + resetImage() + } + + func setImage(light: UIImage, dark: UIImage) { + lightImage = light + darkImage = dark + resetImage() + } + + private func resetImage() { + switch traitCollection.userInterfaceStyle { + case .light: + setImage(lightImage, for: .normal) + case .dark, + .unspecified: + setImage(darkImage, for: .normal) + @unknown default: + assertionFailure() + } + } + + } +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index b600924a..39916741 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -56,14 +56,25 @@ final class StatusTableViewCell: UITableViewCell { var observations = Set() let statusView = StatusView() - + let threadMetaStackView = UIStackView() + let threadMetaView = ThreadMetaView() + let separatorLine = UIView.separatorLine + + var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! + + var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! + override func prepareForReuse() { super.prepareForReuse() + selectionStyle = .default statusView.isStatusTextSensitive = false statusView.cleanUpContentWarning() statusView.pollTableView.dataSource = nil statusView.playerContainerView.reset() statusView.playerContainerView.isHidden = true + threadMetaView.isHidden = true disposeBag.removeAll() observations.removeAll() } @@ -90,7 +101,6 @@ final class StatusTableViewCell: UITableViewCell { extension StatusTableViewCell { private func _init() { - selectionStyle = .none backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color @@ -102,24 +112,74 @@ extension StatusTableViewCell { contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), ]) - let bottomPaddingView = UIView() - bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(bottomPaddingView) + threadMetaStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(threadMetaStackView) NSLayoutConstraint.activate([ - bottomPaddingView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), - bottomPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - bottomPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - bottomPaddingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - bottomPaddingView.heightAnchor.constraint(equalToConstant: StatusTableViewCell.bottomPaddingHeight).priority(.defaultHigh), + threadMetaStackView.topAnchor.constraint(equalTo: statusView.bottomAnchor), + threadMetaStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + threadMetaStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + threadMetaStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) - bottomPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - + threadMetaStackView.addArrangedSubview(threadMetaView) + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) + separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor) + separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor) + NSLayoutConstraint.activate([ + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + resetSeparatorLineLayout() + statusView.delegate = self statusView.pollTableView.delegate = self statusView.statusMosaicImageViewContainer.delegate = self statusView.actionToolbarContainer.delegate = self + + // default hidden + threadMetaView.isHidden = true } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + resetSeparatorLineLayout() + } + +} + +extension StatusTableViewCell { + private func resetSeparatorLineLayout() { + separatorLineToEdgeLeadingLayoutConstraint.isActive = false + separatorLineToEdgeTrailingLayoutConstraint.isActive = false + separatorLineToMarginLeadingLayoutConstraint.isActive = false + separatorLineToMarginTrailingLayoutConstraint.isActive = false + + if traitCollection.userInterfaceIdiom == .phone { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + if traitCollection.horizontalSizeClass == .compact { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + // to margin + NSLayoutConstraint.activate([ + separatorLineToMarginLeadingLayoutConstraint, + separatorLineToMarginTrailingLayoutConstraint, + ]) + } + } + } } // MARK: - UITableViewDelegate diff --git a/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift new file mode 100644 index 00000000..10ad0c5c --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift @@ -0,0 +1,124 @@ +// +// ThreadReplyLoaderTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-13. +// + +import os.log +import UIKit +import Combine + +protocol ThreadReplyLoaderTableViewCellDelegate: class { + func threadReplyLoaderTableViewCell(_ cell: ThreadReplyLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) +} + +final class ThreadReplyLoaderTableViewCell: UITableViewCell { + + static let cellHeight: CGFloat = 44 + + weak var delegate: ThreadReplyLoaderTableViewCellDelegate? + + let loadMoreButton: UIButton = { + let button = HighlightDimmableButton() + button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont + button.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) + button.setTitle(L10n.Common.Controls.Timeline.Loader.showMoreReplies, for: .normal) + return button + }() + + let separatorLine = UIView.separatorLine + + var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! + + var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + resetSeparatorLineLayout() + } + +} + +extension ThreadReplyLoaderTableViewCell { + + func _init() { + selectionStyle = .none + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + loadMoreButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(loadMoreButton) + NSLayoutConstraint.activate([ + loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor), + loadMoreButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor), + loadMoreButton.heightAnchor.constraint(equalToConstant: ThreadReplyLoaderTableViewCell.cellHeight).priority(.required - 1), + ]) + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) + separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor) + separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor) + NSLayoutConstraint.activate([ + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + resetSeparatorLineLayout() + + loadMoreButton.addTarget(self, action: #selector(ThreadReplyLoaderTableViewCell.loadMoreButtonDidPressed(_:)), for: .touchUpInside) + } + + private func resetSeparatorLineLayout() { + separatorLineToEdgeLeadingLayoutConstraint.isActive = false + separatorLineToEdgeTrailingLayoutConstraint.isActive = false + separatorLineToMarginLeadingLayoutConstraint.isActive = false + separatorLineToMarginTrailingLayoutConstraint.isActive = false + + if traitCollection.userInterfaceIdiom == .phone { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + if traitCollection.horizontalSizeClass == .compact { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + // to margin + NSLayoutConstraint.activate([ + separatorLineToMarginLeadingLayoutConstraint, + separatorLineToMarginTrailingLayoutConstraint, + ]) + } + } + } + +} + +extension ThreadReplyLoaderTableViewCell { + @objc private func loadMoreButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.threadReplyLoaderTableViewCell(self, loadMoreButtonDidPressed: sender) + } +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift index 38bf7ef7..dc144d10 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift @@ -10,9 +10,9 @@ import Combine class TimelineLoaderTableViewCell: UITableViewCell { - static let buttonHeight: CGFloat = 62 - static let cellHeight: CGFloat = TimelineLoaderTableViewCell.buttonHeight + 17 - static let extraTopPadding: CGFloat = 10 + static let buttonHeight: CGFloat = 44 + static let buttonMargin: CGFloat = 12 + static let cellHeight: CGFloat = buttonHeight + 2 * buttonMargin static let labelFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .medium)) var disposeBag = Set() @@ -78,10 +78,10 @@ class TimelineLoaderTableViewCell: UITableViewCell { loadMoreButton.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(loadMoreButton) NSLayoutConstraint.activate([ - loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 7), + loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: TimelineLoaderTableViewCell.buttonMargin), loadMoreButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), contentView.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: 14), + contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: TimelineLoaderTableViewCell.buttonMargin), loadMoreButton.heightAnchor.constraint(equalToConstant: TimelineLoaderTableViewCell.buttonHeight).priority(.required - 1), ]) diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift index 75c06a33..7438f5bf 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift @@ -18,11 +18,8 @@ protocol TimelineMiddleLoaderTableViewCellDelegate: class { final class TimelineMiddleLoaderTableViewCell: TimelineLoaderTableViewCell { weak var delegate: TimelineMiddleLoaderTableViewCellDelegate? - let sawToothView: SawToothView = { - let sawToothView = SawToothView() - sawToothView.translatesAutoresizingMaskIntoConstraints = false - return sawToothView - }() + let topSawToothView = SawToothView() + let bottomSawToothView = SawToothView() override func _init() { super._init() @@ -34,12 +31,23 @@ final class TimelineMiddleLoaderTableViewCell: TimelineLoaderTableViewCell { loadMoreButton.setInsets(forContentPadding: .zero, imageTitlePadding: 4) loadMoreButton.addTarget(self, action: #selector(TimelineMiddleLoaderTableViewCell.loadMoreButtonDidPressed(_:)), for: .touchUpInside) - contentView.addSubview(sawToothView) + topSawToothView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(topSawToothView) NSLayoutConstraint.activate([ - sawToothView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - sawToothView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - sawToothView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - sawToothView.heightAnchor.constraint(equalToConstant: 3), + topSawToothView.topAnchor.constraint(equalTo: contentView.topAnchor), + topSawToothView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + topSawToothView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + topSawToothView.heightAnchor.constraint(equalToConstant: 3), + ]) + topSawToothView.transform = CGAffineTransform(scaleX: 1, y: -1) // upside down + + bottomSawToothView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(bottomSawToothView) + NSLayoutConstraint.activate([ + bottomSawToothView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + bottomSawToothView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + bottomSawToothView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + bottomSawToothView.heightAnchor.constraint(equalToConstant: 3), ]) } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineTopLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineTopLoaderTableViewCell.swift new file mode 100644 index 00000000..4accee1d --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineTopLoaderTableViewCell.swift @@ -0,0 +1,36 @@ +// +// TimelineTopLoaderTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import UIKit +import Combine + +final class TimelineTopLoaderTableViewCell: TimelineLoaderTableViewCell { + override func _init() { + super._init() + + activityIndicatorView.isHidden = false + + startAnimating() + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct TimelineTopLoaderTableViewCell_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + TimelineTopLoaderTableViewCell() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Thread/CachedThreadViewModel.swift b/Mastodon/Scene/Thread/CachedThreadViewModel.swift new file mode 100644 index 00000000..d4866b0b --- /dev/null +++ b/Mastodon/Scene/Thread/CachedThreadViewModel.swift @@ -0,0 +1,15 @@ +// +// CachedThreadViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import Foundation +import CoreDataStack + +final class CachedThreadViewModel: ThreadViewModel { + init(context: AppContext, status: Status) { + super.init(context: context, optionalStatus: status) + } +} diff --git a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift new file mode 100644 index 00000000..e79c355c --- /dev/null +++ b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift @@ -0,0 +1,50 @@ +// +// RemoteThreadViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import os.log +import UIKit +import CoreDataStack +import MastodonSDK + +final class RemoteThreadViewModel: ThreadViewModel { + + init(context: AppContext, statusID: Mastodon.Entity.Status.ID) { + super.init(context: context, optionalStatus: nil) + + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let domain = activeMastodonAuthenticationBox.domain + context.apiService.status( + domain: domain, + statusID: statusID, + authorizationBox: activeMastodonAuthenticationBox + ) + .retry(3) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote status %s fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, statusID, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote status %s fetched", ((#file as NSString).lastPathComponent), #line, #function, statusID) + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + let managedObjectContext = context.managedObjectContext + let request = Status.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = Status.predicate(domain: domain, id: response.value.id) + guard let status = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + self.rootItem.value = .root(statusObjectID: status.objectID, attribute: Item.StatusAttribute()) + } + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/Thread/ThreadViewController+StatusProvider.swift b/Mastodon/Scene/Thread/ThreadViewController+StatusProvider.swift new file mode 100644 index 00000000..05cc6e4b --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewController+StatusProvider.swift @@ -0,0 +1,88 @@ +// +// ThreadViewController+StatusProvider.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack + +// MARK: - StatusProvider +extension ThreadViewController: StatusProvider { + + func status() -> Future { + return Future { promise in promise(.success(nil)) } + } + + func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { + return Future { promise in + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + promise(.success(nil)) + return + } + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + + switch item { + case .root(let statusObjectID, _), + .reply(let statusObjectID, _), + .leaf(let statusObjectID, _): + let managedObjectContext = self.viewModel.context.managedObjectContext + managedObjectContext.perform { + let status = managedObjectContext.object(with: statusObjectID) as? Status + promise(.success(status)) + } + default: + promise(.success(nil)) + } + } + } + + func status(for cell: UICollectionViewCell) -> Future { + return Future { promise in promise(.success(nil)) } + } + + var managedObjectContext: NSManagedObjectContext { + return viewModel.context.managedObjectContext + } + + var tableViewDiffableDataSource: UITableViewDiffableDataSource? { + return viewModel.diffableDataSource + } + + func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return nil + } + + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + return nil + } + + return item + } + + func items(indexPaths: [IndexPath]) -> [Item] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return [] + } + + var items: [Item] = [] + for indexPath in indexPaths { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } + items.append(item) + } + return items + } + +} diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift new file mode 100644 index 00000000..43c40025 --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -0,0 +1,210 @@ +// +// ThreadViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import os.log +import UIKit +import Combine +import CoreData +import AVKit + +final class ThreadViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: ThreadViewModel! + + let titleView = DoubleTitleLabelNavigationBarTitleView() + + let replyBarButtonItem = AdaptiveUserInterfaceStyleBarButtonItem( + lightImage: UIImage(systemName: "arrowshape.turn.up.left")!, + darkImage: UIImage(systemName: "arrowshape.turn.up.left.fill")! + ) + + 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.register(ThreadReplyLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: ThreadReplyLoaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + + return tableView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ThreadViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + navigationItem.title = L10n.Scene.Thread.backTitle + navigationItem.titleView = titleView + navigationItem.rightBarButtonItem = replyBarButtonItem + replyBarButtonItem.button.addTarget(self, action: #selector(ThreadViewController.replyBarButtonItemPressed(_:)), for: .touchUpInside) + + viewModel.tableView = tableView + viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self + tableView.delegate = self + tableView.prefetchDataSource = self + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + statusTableViewCellDelegate: self, + threadReplyLoaderTableViewCellDelegate: self + ) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + viewModel.navigationBarTitle + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + guard let self = self else { return } + self.titleView.update(title: title ?? L10n.Scene.Thread.backTitle, subtitle: nil) + } + .store(in: &disposeBag) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // force readable layout frame update + tableView.reloadData() + aspectViewWillAppear(animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + aspectViewDidDisappear(animated) + } + +} + +extension ThreadViewController { + @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + } +} + +// MARK: - StatusTableViewControllerAspect +extension ThreadViewController: StatusTableViewControllerAspect { } + +// MARK: - TableViewCellHeightCacheableContainer +extension ThreadViewController: TableViewCellHeightCacheableContainer { + var cellFrameCache: NSCache { viewModel.cellFrameCache } +} + +// MARK: - UITableViewDelegate +extension ThreadViewController: 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, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil } + + // disable root selection + switch item { + case .root: + return nil + default: + return indexPath + } + } + +} + +// MARK: - UITableViewDataSourcePrefetching +extension ThreadViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + aspectTableView(tableView, prefetchRowsAt: indexPaths) + } +} + +// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate +extension ThreadViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { + func navigationBar() -> UINavigationBar? { + return navigationController?.navigationBar + } +} + +// MARK: - AVPlayerViewControllerDelegate +extension ThreadViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + +// MARK: - statusTableViewCellDelegate +extension ThreadViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} + +// MARK: - ThreadReplyLoaderTableViewCellDelegate +extension ThreadViewController: ThreadReplyLoaderTableViewCellDelegate { + func threadReplyLoaderTableViewCell(_ cell: ThreadReplyLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard case let .leafBottomLoader(statusObjectID) = item else { return } + + let nodes = viewModel.descendantNodes.value + nodes.forEach { node in + expandReply(node: node, statusObjectID: statusObjectID) + } + viewModel.descendantNodes.value = nodes + } + + private func expandReply(node: ThreadViewModel.LeafNode, statusObjectID: NSManagedObjectID) { + if node.objectID == statusObjectID { + node.isChildrenExpanded = true + } else { + for child in node.children { + expandReply(node: child, statusObjectID: statusObjectID) + } + } + } +} diff --git a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift new file mode 100644 index 00000000..323a7a54 --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift @@ -0,0 +1,186 @@ +// +// ThreadViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import UIKit +import Combine +import CoreData + +extension ThreadViewModel { + + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + statusTableViewCellDelegate: StatusTableViewCellDelegate, + threadReplyLoaderTableViewCellDelegate: ThreadReplyLoaderTableViewCellDelegate + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = StatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: context.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher, + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: nil, + threadReplyLoaderTableViewCellDelegate: threadReplyLoaderTableViewCellDelegate + ) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + if let rootNode = self.rootNode.value, rootNode.replyToID != nil { + snapshot.appendItems([.topLoader], toSection: .main) + } + + diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + + Publishers.CombineLatest3( + rootItem, + ancestorItems, + descendantItems + ) + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) // some magic to avoid jitter + .receive(on: DispatchQueue.main) + .sink { [weak self] rootItem, ancestorItems, descendantItems in + guard let self = self else { return } + guard let tableView = self.tableView, + let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() + else { return } + + guard let diffableDataSource = self.diffableDataSource else { return } + let oldSnapshot = diffableDataSource.snapshot() + + var newSnapshot = NSDiffableDataSourceSnapshot() + newSnapshot.appendSections([.main]) + + let currentState = self.loadThreadStateMachine.currentState + + // reply to + if self.rootNode.value?.replyToID != nil, !(currentState is LoadThreadState.NoMore) { + newSnapshot.appendItems([.topLoader], toSection: .main) + } + newSnapshot.appendItems(ancestorItems, toSection: .main) + + // root + if let rootItem = rootItem { + switch rootItem { + case .root: + newSnapshot.appendItems([rootItem], toSection: .main) + default: + break + } + } + + // leaf + if !(currentState is LoadThreadState.NoMore) { + newSnapshot.appendItems([.bottomLoader], toSection: .main) + } + newSnapshot.appendItems(descendantItems, toSection: .main) + + // difference for first visiable item exclude .topLoader + guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { + diffableDataSource.apply(newSnapshot) + return + } + + // addtional margin for .topLoader + let oldTopMargin: CGFloat = { + let marginHeight = TimelineTopLoaderTableViewCell.cellHeight + if oldSnapshot.itemIdentifiers.contains(.topLoader) { + return marginHeight + } + if !ancestorItems.isEmpty { + return marginHeight + } + + return .zero + }() + + let oldRootCell: UITableViewCell? = { + guard let rootItem = rootItem else { return nil } + guard let index = oldSnapshot.indexOfItem(rootItem) else { return nil } + guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) else { return nil } + return cell + }() + // save height before cell reuse + let oldRootCellHeight = oldRootCell?.frame.height + + diffableDataSource.apply(newSnapshot, animatingDifferences: false) { + guard let _ = rootItem else { + return + } + if let oldRootCellHeight = oldRootCellHeight { + // set bottom inset. Make root item pin to top (with margin). + let bottomSpacing = tableView.safeAreaLayoutGuide.layoutFrame.height - oldRootCellHeight - oldTopMargin + tableView.contentInset.bottom = max(0, bottomSpacing) + } + + // set scroll position + tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) + let contentOffsetY: CGFloat = { + var offset: CGFloat = tableView.contentOffset.y - difference.offset + if tableView.contentInset.bottom != 0.0 && descendantItems.isEmpty { + // needs restore top margin if bottom inset adjusted AND no descendantItems + offset += oldTopMargin + } + return offset + }() + tableView.setContentOffset(CGPoint(x: 0, y: contentOffsetY), animated: false) + } + } + .store(in: &disposeBag) + } + +} + +extension ThreadViewModel { + private struct Difference { + let item: T + let sourceIndexPath: IndexPath + let targetIndexPath: IndexPath + let offset: CGFloat + } + + private func calculateReloadSnapshotDifference( + navigationBar: UINavigationBar, + tableView: UITableView, + oldSnapshot: NSDiffableDataSourceSnapshot, + newSnapshot: NSDiffableDataSourceSnapshot + ) -> Difference? { + guard oldSnapshot.numberOfItems != 0 else { return nil } + guard let visibleIndexPaths = tableView.indexPathsForVisibleRows?.sorted() else { return nil } + + // find index of the first visible item exclude .topLoader + var _index: Int? + let items = oldSnapshot.itemIdentifiers(inSection: .main) + for (i, item) in items.enumerated() { + if case .topLoader = item { continue } + guard visibleIndexPaths.contains(where: { $0.row == i }) else { continue } + + _index = i + break + } + + guard let index = _index else { return nil } + let sourceIndexPath = IndexPath(row: index, section: 0) + guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil } + + let item = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row] + guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: item) else { return nil } + let targetIndexPath = IndexPath(row: itemIndex, section: 0) + + let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: sourceIndexPath, navigationBar: navigationBar) + return Difference( + item: item, + sourceIndexPath: sourceIndexPath, + targetIndexPath: targetIndexPath, + offset: offset + ) + } +} diff --git a/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift new file mode 100644 index 00000000..5327edc5 --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift @@ -0,0 +1,127 @@ +// +// ThreadViewModel+LoadThreadState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import os.log +import Foundation +import Combine +import GameplayKit +import CoreDataStack +import MastodonSDK + +extension ThreadViewModel { + class LoadThreadState: GKState { + weak var viewModel: ThreadViewModel? + + init(viewModel: ThreadViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension ThreadViewModel.LoadThreadState { + class Initial: ThreadViewModel.LoadThreadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: return true + default: return false + } + } + } + + class Loading: ThreadViewModel.LoadThreadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: return true + case is NoMore.Type: return true + default: return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let mastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + guard let rootNode = viewModel.rootNode.value else { + stateMachine.enter(Fail.self) + return + } + + // trigger data source update + viewModel.rootItem.value = viewModel.rootItem.value + + let domain = rootNode.domain + let statusID = rootNode.statusID + let replyToID = rootNode.replyToID + + viewModel.context.apiService.statusContext( + domain: domain, + statusID: statusID, + mastodonAuthenticationBox: mastodonAuthenticationBox + ) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch status context for %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, statusID, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + stateMachine.enter(NoMore.self) + + viewModel.ancestorNodes.value = ThreadViewModel.ReplyNode.replyToThread( + for: replyToID, + from: response.value.ancestors, + domain: domain, + managedObjectContext: viewModel.context.managedObjectContext + ) + viewModel.descendantNodes.value = ThreadViewModel.LeafNode.tree( + for: rootNode.statusID, + from: response.value.descendants, + domain: domain, + managedObjectContext: viewModel.context.managedObjectContext + ) + } + .store(in: &viewModel.disposeBag) + } + + } + + class Fail: ThreadViewModel.LoadThreadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: return true + default: return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + stateMachine.enter(Loading.self) + } + } + } + + class NoMore: ThreadViewModel.LoadThreadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + } + +} diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift new file mode 100644 index 00000000..2f0a9dac --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -0,0 +1,279 @@ +// +// ThreadViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK + +class ThreadViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let rootNode: CurrentValueSubject + let rootItem: CurrentValueSubject + let cellFrameCache = NSCache() + + weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? + weak var tableView: UITableView? + + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var loadThreadStateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + LoadThreadState.Initial(viewModel: self), + LoadThreadState.Loading(viewModel: self), + LoadThreadState.Fail(viewModel: self), + LoadThreadState.NoMore(viewModel: self), + + ]) + stateMachine.enter(LoadThreadState.Initial.self) + return stateMachine + }() + let ancestorNodes = CurrentValueSubject<[ReplyNode], Never>([]) + let ancestorItems = CurrentValueSubject<[Item], Never>([]) + let descendantNodes = CurrentValueSubject<[LeafNode], Never>([]) + let descendantItems = CurrentValueSubject<[Item], Never>([]) + let navigationBarTitle: CurrentValueSubject + + init(context: AppContext, optionalStatus: Status?) { + self.context = context + self.rootNode = CurrentValueSubject(optionalStatus.flatMap { RootNode(domain: $0.domain, statusID: $0.id, replyToID: $0.inReplyToID) }) + self.rootItem = CurrentValueSubject(optionalStatus.flatMap { Item.root(statusObjectID: $0.objectID, attribute: Item.StatusAttribute()) }) + self.navigationBarTitle = CurrentValueSubject( + optionalStatus.flatMap { L10n.Scene.Thread.title($0.author.displayNameWithFallback) } + ) + + rootNode + .receive(on: DispatchQueue.main) + .sink { [weak self] rootNode in + guard let self = self else { return } + guard rootNode != nil else { return } + self.loadThreadStateMachine.enter(LoadThreadState.Loading.self) + } + .store(in: &disposeBag) + + if optionalStatus == nil { + rootItem + .receive(on: DispatchQueue.main) + .sink { [weak self] rootItem in + guard let self = self else { return } + guard case let .root(objectID, _) = rootItem else { return } + self.context.managedObjectContext.perform { + guard let status = self.context.managedObjectContext.object(with: objectID) as? Status else { + return + } + self.rootNode.value = RootNode(domain: status.domain, statusID: status.id, replyToID: status.inReplyToID) + self.navigationBarTitle.value = L10n.Scene.Thread.title(status.author.displayNameWithFallback) + } + } + .store(in: &disposeBag) + } + + // descendantNodes + + ancestorNodes + .receive(on: DispatchQueue.main) + .compactMap { [weak self] nodes -> [Item]? in + guard let self = self else { return nil } + guard !nodes.isEmpty else { return [] } + + guard let diffableDataSource = self.diffableDataSource else { return nil } + let oldSnapshot = diffableDataSource.snapshot() + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + for item in oldSnapshot.itemIdentifiers { + switch item { + case .reply(let objectID, let attribute): + oldSnapshotAttributeDict[objectID] = attribute + default: + break + } + } + + var items: [Item] = [] + for node in nodes { + let attribute = oldSnapshotAttributeDict[node.statusObjectID] ?? Item.StatusAttribute() + items.append(Item.reply(statusObjectID: node.statusObjectID, attribute: attribute)) + } + + return items.reversed() + } + .assign(to: \.value, on: ancestorItems) + .store(in: &disposeBag) + + descendantNodes + .receive(on: DispatchQueue.main) + .compactMap { [weak self] nodes -> [Item]? in + guard let self = self else { return nil } + guard !nodes.isEmpty else { return [] } + + guard let diffableDataSource = self.diffableDataSource else { return nil } + let oldSnapshot = diffableDataSource.snapshot() + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + for item in oldSnapshot.itemIdentifiers { + switch item { + case .leaf(let objectID, let attribute): + oldSnapshotAttributeDict[objectID] = attribute + default: + break + } + } + + var items: [Item] = [] + + func buildThread(node: LeafNode) { + let attribute = oldSnapshotAttributeDict[node.objectID] ?? Item.StatusAttribute() + items.append(Item.leaf(statusObjectID: node.objectID, attribute: attribute)) + // only expand the first child + if let firstChild = node.children.first { + if !node.isChildrenExpanded { + items.append(Item.leafBottomLoader(statusObjectID: node.objectID)) + } else { + buildThread(node: firstChild) + } + } + } + + for node in nodes { + buildThread(node: node) + } + return items + } + .assign(to: \.value, on: descendantItems) + .store(in: &disposeBag) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ThreadViewModel { + + struct RootNode { + let domain: String + let statusID: Mastodon.Entity.Status.ID + let replyToID: Mastodon.Entity.Status.ID? + } + + class ReplyNode { + let statusID: Mastodon.Entity.Status.ID + let statusObjectID: NSManagedObjectID + + init(statusID: Mastodon.Entity.Status.ID, statusObjectID: NSManagedObjectID) { + self.statusID = statusID + self.statusObjectID = statusObjectID + } + + static func replyToThread( + for replyToID: Mastodon.Entity.Status.ID?, + from statuses: [Mastodon.Entity.Status], + domain: String, + managedObjectContext: NSManagedObjectContext + ) -> [ReplyNode] { + guard let replyToID = replyToID else { + return [] + } + + var nodes: [ReplyNode] = [] + managedObjectContext.performAndWait { + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: domain, ids: statuses.map { $0.id }) + request.fetchLimit = statuses.count + let objects = managedObjectContext.safeFetch(request) + + var objectDict: [Mastodon.Entity.Status.ID: Status] = [:] + for object in objects { + objectDict[object.id] = object + } + var nextID: Mastodon.Entity.Status.ID? = replyToID + while let _nextID = nextID { + guard let object = objectDict[_nextID] else { break } + nodes.append(ThreadViewModel.ReplyNode(statusID: _nextID, statusObjectID: object.objectID)) + nextID = object.inReplyToID + } + } + return nodes.reversed() + } + } + + class LeafNode { + let statusID: Mastodon.Entity.Status.ID + let objectID: NSManagedObjectID + let repliesCount: Int + let children: [LeafNode] + + var isChildrenExpanded: Bool = false // default collapsed + + init( + statusID: Mastodon.Entity.Status.ID, + objectID: NSManagedObjectID, + repliesCount: Int, + children: [ThreadViewModel.LeafNode] + ) { + self.statusID = statusID + self.objectID = objectID + self.repliesCount = repliesCount + self.children = children + } + + static func tree( + for statusID: Mastodon.Entity.Status.ID, + from statuses: [Mastodon.Entity.Status], + domain: String, + managedObjectContext: NSManagedObjectContext + ) -> [LeafNode] { + // make an cache collection + var objectDict: [Mastodon.Entity.Status.ID: Status] = [:] + + managedObjectContext.performAndWait { + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: domain, ids: statuses.map { $0.id }) + request.fetchLimit = statuses.count + let objects = managedObjectContext.safeFetch(request) + + for object in objects { + objectDict[object.id] = object + } + } + + var tree: [LeafNode] = [] + let firstTierStatuses = statuses.filter { $0.inReplyToID == statusID } + for status in firstTierStatuses { + guard let node = node(of: status.id, objectDict: objectDict) else { continue } + tree.append(node) + } + + return tree + } + + static func node( + of statusID: Mastodon.Entity.Status.ID, + objectDict: [Mastodon.Entity.Status.ID: Status] + ) -> LeafNode? { + guard let object = objectDict[statusID] else { return nil } + let replies = (object.replyFrom ?? Set()).sorted( + by: { $0.repliesCount?.intValue ?? 0 < $1.repliesCount?.intValue ?? 0 } + ) + let children = replies.compactMap { node(of: $0.id, objectDict: objectDict) } + return LeafNode( + statusID: statusID, + objectID: object.objectID, + repliesCount: object.repliesCount?.intValue ?? 0, + children: children + ) + } + } + +} + diff --git a/Mastodon/Service/APIService/APIService+Thread.swift b/Mastodon/Service/APIService/APIService+Thread.swift new file mode 100644 index 00000000..2633518c --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Thread.swift @@ -0,0 +1,57 @@ +// +// APIService+Thread.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService { + + func statusContext( + domain: String, + statusID: Mastodon.Entity.Status.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + guard domain == mastodonAuthenticationBox.domain else { + return Fail(error: APIError.implicit(.badRequest)).eraseToAnyPublisher() + } + + return Mastodon.API.Statuses.statusContext( + session: session, + domain: domain, + statusID: statusID, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return APIService.Persist.persistStatus( + managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: nil, + response: response.map { $0.ancestors + $0.descendants }, + persistType: .lookUp, + requestMastodonUserID: nil, + log: OSLog.api + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift index 9bc699b7..f5bb4ea3 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift @@ -221,6 +221,13 @@ extension APIService.Persist { break } + // reply relationship link + for (_, status) in statusCache.dictionary { + guard let replyToID = status.inReplyToID, status.replyTo == nil else { continue } + guard let replyTo = statusCache.dictionary[replyToID] else { continue } + status.update(replyTo: replyTo) + } + // print working record tree map #if DEBUG DispatchQueue.global(qos: .utility).async { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift index da54c934..ae5d5e67 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -146,3 +146,46 @@ extension Mastodon.API.Statuses { } } + +extension Mastodon.API.Statuses { + + static func statusContextEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("statuses/\(statusID)/context") + } + + /// Parent and child statuses + /// + /// View statuses above and below this status in the thread. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/12 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id of status + /// - authorization: User token. Optional for public statuses + /// - Returns: `AnyPublisher` contains `Context` nested in the response + public static func statusContext( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: statusContextEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Context.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +}