diff --git a/Localization/app.json b/Localization/app.json index c0a305d96..99868a8fb 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -18,6 +18,10 @@ "discard_post_content": { "title": "Discard Publish", "message": "Confirm discard composed post content." + }, + "publish_post_failure": { + "title": "Publish Failure", + "message": "Failed to publish the post.\nPlease check your internet connection." } }, "controls": { @@ -32,6 +36,7 @@ "continue": "Continue", "cancel": "Cancel", "discard": "Discard", + "try_again": "Try Again", "take_photo": "Take photo", "save_photo": "Save photo", "sign_in": "Sign In", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index aff684185..f0d5fa084 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -73,8 +73,8 @@ 2D7631B325C159F700929FB9 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631B225C159F700929FB9 /* Item.swift */; }; 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */; }; 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */; }; - 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */; }; - 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */; }; + 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */; }; + 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */; }; 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D84350425FF858100EECE90 /* UIScrollView.swift */; }; 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0125C7E4F2004F19B8 /* Mention.swift */; }; 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; }; @@ -246,6 +246,7 @@ DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.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 */; }; DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; @@ -370,8 +371,8 @@ 2D7631B225C159F700929FB9 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; }; 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewControllerAppearance.swift; sourceTree = ""; }; 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModelNavigationDelegateShim.swift; sourceTree = ""; }; - 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarState.swift; sourceTree = ""; }; - 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarView.swift; sourceTree = ""; }; + 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleViewModel.swift; sourceTree = ""; }; + 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleView.swift; sourceTree = ""; }; 2D84350425FF858100EECE90 /* UIScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScrollView.swift; sourceTree = ""; }; 2D927F0125C7E4F2004F19B8 /* Mention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = ""; }; 2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; @@ -555,6 +556,7 @@ DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.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 = ""; }; DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; @@ -707,6 +709,7 @@ 2D38F1D325CD463600561493 /* HomeTimeline */ = { isa = PBXGroup; children = ( + DB1F239626117C360057430E /* View */, 2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */, 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift */, 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */, @@ -715,8 +718,6 @@ 2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */, 2D38F1F025CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift */, 2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */, - 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */, - 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */, ); path = HomeTimeline; sourceTree = ""; @@ -785,6 +786,7 @@ 2DA6054625F716A2006356F9 /* PlaybackState.swift */, 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, + DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, ); path = Service; sourceTree = ""; @@ -970,6 +972,15 @@ path = TableView; sourceTree = ""; }; + DB1F239626117C360057430E /* View */ = { + isa = PBXGroup; + children = ( + 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */, + 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */, + ); + path = View; + sourceTree = ""; + }; DB3D0FF725BAA68500EAA174 /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -1832,12 +1843,12 @@ 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, - 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */, + 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */, - 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */, + 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, @@ -1925,6 +1936,7 @@ 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, + DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 8bf3b168b..82e7f8b1f 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -44,6 +44,7 @@ internal enum Asset { internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") + internal static let success = ColorAsset(name: "Colors/Background/success") internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background") internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background") internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index a875994ea..82fa696d8 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -25,6 +25,12 @@ internal enum L10n { /// Discard Publish internal static let title = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Title") } + internal enum PublishPostFailure { + /// Failed to publish the post.\nPlease check your internet connection. + internal static let message = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Message") + /// Publish Failure + internal static let title = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Title") + } internal enum ServerError { /// Server Error internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title") @@ -76,6 +82,8 @@ internal enum L10n { internal static let signUp = L10n.tr("Localizable", "Common.Controls.Actions.SignUp") /// Take photo internal static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto") + /// Try Again + internal static let tryAgain = L10n.tr("Localizable", "Common.Controls.Actions.TryAgain") } internal enum Status { /// Tap to reveal that may be sensitive diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/success.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/success.colorset/Contents.json new file mode 100644 index 000000000..8716dcb74 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/success.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.604", + "green" : "0.741", + "red" : "0.475" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index d2ebb4071..c2bc09c68 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -2,6 +2,9 @@ "Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; "Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content."; "Common.Alerts.DiscardPostContent.Title" = "Discard Publish"; +"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post. +Please check your internet connection."; +"Common.Alerts.PublishPostFailure.Title" = "Publish Failure"; "Common.Alerts.ServerError.Title" = "Server Error"; "Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; "Common.Alerts.VoteFailure.PollExpired" = "The poll has expired"; @@ -23,6 +26,7 @@ "Common.Controls.Actions.SignIn" = "Sign In"; "Common.Controls.Actions.SignUp" = "Sign Up"; "Common.Controls.Actions.TakePhoto" = "Take photo"; +"Common.Controls.Actions.TryAgain" = "Try Again"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; "Common.Controls.Status.Poll.Closed" = "Closed"; "Common.Controls.Status.Poll.TimeLeft" = "%@ left"; diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 5e3a6a8a9..53f71fc31 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -483,7 +483,7 @@ extension ComposeViewController { // TODO: handle error return } - + context.statusPublishService.publish(composeViewModel: viewModel) dismiss(animated: true, completion: nil) } diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift index d5047cc9f..c3e903812 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -21,6 +21,7 @@ extension ComposeViewModel { override func didEnter(from previousState: GKState?) { os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + viewModel?.publishStateMachinePublisher.value = self } } } @@ -48,6 +49,8 @@ extension ComposeViewModel.PublishState { return } + viewModel.updatePublishDate() + let domain = mastodonAuthenticationBox.domain let attachmentServices = viewModel.attachmentServices.value let mediaIDs = attachmentServices.compactMap { attachmentService in @@ -131,7 +134,13 @@ extension ComposeViewModel.PublishState { class Fail: ComposeViewModel.PublishState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { // allow discard publishing - return stateClass == Publishing.self || stateClass == Finish.self + return stateClass == Publishing.self || stateClass == Discard.self + } + } + + class Discard: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false } } diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 036351d87..52ca4cc88 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -39,12 +39,15 @@ final class ComposeViewModel { PublishState.Initial(viewModel: self), PublishState.Publishing(viewModel: self), PublishState.Fail(viewModel: self), + PublishState.Discard(viewModel: self), PublishState.Finish(viewModel: self), ]) stateMachine.enter(PublishState.Initial.self) return stateMachine }() - + private(set) lazy var publishStateMachinePublisher = CurrentValueSubject(nil) + private(set) var publishDate = Date() // update it when enter Publishing state + // UI & UX let title: CurrentValueSubject let shouldDismiss = CurrentValueSubject(true) @@ -316,6 +319,10 @@ extension ComposeViewModel { let attribute = ComposeStatusItem.ComposePollOptionAttribute() pollOptionAttributes.value = pollOptionAttributes.value + [attribute] } + + func updatePublishDate() { + publishDate = Date() + } } // MARK: - MastodonAttachmentServiceDelegate diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift deleted file mode 100644 index 2d1da2165..000000000 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift +++ /dev/null @@ -1,156 +0,0 @@ -// -// HomeTimelineNavigationBarState.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/15. -// - -import Combine -import Foundation -import UIKit - -final class HomeTimelineNavigationBarState { - static let errorCountMax: Int = 3 - var disposeBag = Set() - var errorCountDownDispose: AnyCancellable? - var timerDispose: AnyCancellable? - var networkErrorCountSubject = PassthroughSubject() - - var newTopContent = CurrentValueSubject(false) - var hasContentBeforeFetching: Bool = true - - weak var viewController: HomeTimelineViewController? - - let timestampUpdatePublisher = Timer.publish(every: NavigationBarProgressView.progressAnimationDuration, on: .main, in: .common) - .autoconnect() - .share() - .eraseToAnyPublisher() - - init() { - reCountdown() - subscribeNewContent() - addGesture() - } -} - -extension HomeTimelineNavigationBarState { - func showOfflineInNavigationBar() { - HomeTimelineNavigationBarView.progressView.removeFromSuperview() - viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.offlineView - } - - func showNewPostsInNavigationBar() { - HomeTimelineNavigationBarView.progressView.removeFromSuperview() - viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.newPostsView - } - - func showPublishingNewPostInNavigationBar() { - let progressView = HomeTimelineNavigationBarView.progressView - if let navigationBar = viewController?.navigationBar(), progressView.superview == nil { - navigationBar.addSubview(progressView) - NSLayoutConstraint.activate([ - progressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor), - progressView.leadingAnchor.constraint(equalTo: navigationBar.leadingAnchor), - progressView.trailingAnchor.constraint(equalTo: navigationBar.trailingAnchor), - progressView.heightAnchor.constraint(equalToConstant: 3) - ]) - } - progressView.layoutIfNeeded() - progressView.progress = 0 - viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.publishingLabel - - var times: Int = 0 - timerDispose = timestampUpdatePublisher - .map { _ in - times += 1 - return Double(times) - } - .scan(0) { value, count in - value + 1 / pow(Double(2), count) - } - .receive(on: DispatchQueue.main) - .sink { value in - print(value) - progressView.progress = CGFloat(value) - } - } - - func showPublishedInNavigationBar() { - timerDispose = nil - HomeTimelineNavigationBarView.progressView.removeFromSuperview() - viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.publishedView - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) { - self.showMastodonLogoInNavigationBar() - } - } - - func showMastodonLogoInNavigationBar() { - HomeTimelineNavigationBarView.progressView.removeFromSuperview() - viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.mastodonLogoTitleView - } -} - -extension HomeTimelineNavigationBarState { - func handleScrollViewDidScroll(_ scrollView: UIScrollView) { - let contentOffsetY = scrollView.contentOffset.y - let isShowingNewPostsNew = viewController?.navigationItem.titleView === HomeTimelineNavigationBarView.newPostsView - if !isShowingNewPostsNew { - return - } - let isTop = contentOffsetY < -scrollView.contentInset.top - if isTop { - newTopContent.value = false - showMastodonLogoInNavigationBar() - } - } - - func addGesture() { - let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer - tapGesture.addTarget(self, action: #selector(HomeTimelineNavigationBarState.newPostsNewDidPressed(_:))) - HomeTimelineNavigationBarView.newPostsView.addGestureRecognizer(tapGesture) - } - - @objc func newPostsNewDidPressed(_ sender: UITapGestureRecognizer) { - if newTopContent.value == true { - viewController?.tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: true) - } - } -} - -extension HomeTimelineNavigationBarState { - func subscribeNewContent() { - newTopContent - .receive(on: DispatchQueue.main) - .sink { [weak self] newContent in - guard let self = self else { return } - if self.hasContentBeforeFetching, newContent { - self.showNewPostsInNavigationBar() - } - } - .store(in: &disposeBag) - } - - func reCountdown() { - errorCountDownDispose = networkErrorCountSubject - .scan(0) { value, _ in value + 1 } - .sink(receiveValue: { [weak self] errorCount in - guard let self = self else { return } - if errorCount >= HomeTimelineNavigationBarState.errorCountMax { - self.showOfflineInNavigationBar() - } - }) - } - - func receiveCompletion(completion: Subscribers.Completion) { - switch completion { - case .failure: - networkErrorCountSubject.send(false) - case .finished: - reCountdown() - let isShowingOfflineView = viewController?.navigationItem.titleView === HomeTimelineNavigationBarView.offlineView - if isShowingOfflineView { - showMastodonLogoInNavigationBar() - } - } - } -} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift deleted file mode 100644 index b14b42aa8..000000000 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// HomeTimelineNavigationBarView.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/15. -// - -import UIKit - -final class HomeTimelineNavigationBarView { - static let mastodonLogoTitleView: UIImageView = { - let imageView = UIImageView(image: Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate)) - imageView.tintColor = Asset.Colors.Label.primary.color - return imageView - }() - - static let offlineView: UIView = { - let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.danger.color) - let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.offline) - HomeTimelineNavigationBarView.addLabelToView(label: label, view: view) - return view - }() - - static let newPostsView: UIView = { - let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.Button.normal.color) - let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.newPosts) - HomeTimelineNavigationBarView.addLabelToView(label: label, view: view) - return view - }() - - static var publishedView: UIView = { - let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.lightSuccessGreen.color) - let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.published) - HomeTimelineNavigationBarView.addLabelToView(label: label, view: view) - return view - }() - - static var progressView: NavigationBarProgressView = { - let view = NavigationBarProgressView() - return view - }() - - static var publishingLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.textColor = .black - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) - label.text = L10n.Scene.HomeTimeline.NavigationBarState.publishing - return label - }() - - static func addLabelToView(label: UILabel, view: UIView) { - view.addSubview(label) - NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - view.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: 16), - label.topAnchor.constraint(equalTo: view.topAnchor, constant: 1), - view.bottomAnchor.constraint(equalTo: label.bottomAnchor, constant: 1), - view.heightAnchor.constraint(equalToConstant: 24), - ]) - } - - static func backgroundViewWithColor(color: UIColor) -> UIView { - let view = UIView() - view.backgroundColor = color - view.translatesAutoresizingMaskIntoConstraints = false - view.layer.cornerRadius = 12 - view.clipsToBounds = true - return view - } - - static func contentLabel(text: String) -> UILabel { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.textColor = .white - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .bold)) - label.text = text - return label - } -} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 6efdb76a3..078b7b445 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -23,6 +23,8 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency { var disposeBag = Set() private(set) lazy var viewModel = HomeTimelineViewModel(context: context) + let titleView = HomeTimelineNavigationBarTitleView() + let settingBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem() barButtonItem.tintColor = Asset.Colors.Label.highlight.color @@ -49,6 +51,12 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency { return tableView }() + let publishProgressView: UIProgressView = { + let progressView = UIProgressView(progressViewStyle: .bar) + progressView.alpha = 0 + return progressView + }() + let refreshControl = UIRefreshControl() deinit { @@ -64,8 +72,19 @@ extension HomeTimelineViewController { title = L10n.Scene.HomeTimeline.title view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - navigationItem.titleView = HomeTimelineNavigationBarView.mastodonLogoTitleView navigationItem.leftBarButtonItem = settingBarButtonItem + navigationItem.titleView = titleView + titleView.delegate = self + + viewModel.homeTimelineNavigationBarTitleViewModel.state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + self.titleView.configure(state: state) + } + .store(in: &disposeBag) + + #if DEBUG // long press to trigger debug menu settingBarButtonItem.menu = debugMenu @@ -95,9 +114,16 @@ extension HomeTimelineViewController { tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + + publishProgressView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(publishProgressView) + NSLayoutConstraint.activate([ + publishProgressView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + publishProgressView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + publishProgressView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) viewModel.tableView = tableView - viewModel.viewController = self viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self tableView.delegate = self tableView.prefetchDataSource = self @@ -121,9 +147,35 @@ extension HomeTimelineViewController { } } .store(in: &disposeBag) + + viewModel.homeTimelineNavigationBarTitleViewModel.publishingProgress + .receive(on: DispatchQueue.main) + .sink { [weak self] progress in + guard let self = self else { return } + guard progress > 0 else { + let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut) + dismissAnimator.addAnimations { + self.publishProgressView.alpha = 0 + } + dismissAnimator.addCompletion { _ in + self.publishProgressView.setProgress(0, animated: false) + } + dismissAnimator.startAnimation() + return + } + if self.publishProgressView.alpha == 0 { + let progressAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeOut) + progressAnimator.addAnimations { + self.publishProgressView.alpha = 1 + } + progressAnimator.startAnimation() + } + + self.publishProgressView.setProgress(progress, animated: true) + } + .store(in: &disposeBag) } - override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -207,7 +259,7 @@ extension HomeTimelineViewController { extension HomeTimelineViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { handleScrollViewDidScroll(scrollView) - self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView) + self.viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) } } @@ -221,8 +273,9 @@ extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer { // MARK: - UITableViewDelegate extension HomeTimelineViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return 200 // TODO: - // func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { // guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } // guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } // @@ -232,7 +285,7 @@ extension HomeTimelineViewController: UITableViewDelegate { // // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) // // return ceil(frame.height) - // } + } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) @@ -364,3 +417,23 @@ extension HomeTimelineViewController: StatusTableViewCellDelegate { weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } func parent() -> UIViewController { return self } } + +// MARK: - HomeTimelineNavigationBarTitleViewDelegate +extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate { + func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) { + switch titleView.state { + case .newPostButton: + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let indexPath = IndexPath(row: 0, section: 0) + guard diffableDataSource.itemIdentifier(for: indexPath) != nil else { return } + tableView.scrollToRow(at: indexPath, at: .top, animated: true) + case .offlineButton: + // TODO: retry + break + case .publishedButton: + break + default: + break + } + } +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index 0df4334a0..80c86a006 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -73,7 +73,7 @@ extension HomeTimelineViewModel.LoadLatestState { stateMachine.enter(Fail.self) return } - viewModel.homeTimelineNavigationBarState.hasContentBeforeFetching = !latestTootIDs.isEmpty + let end = CACurrentMediaTime() os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect toots id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) @@ -81,7 +81,7 @@ extension HomeTimelineViewModel.LoadLatestState { viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) .receive(on: DispatchQueue.main) .sink { completion in - viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) + viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) switch completion { case .failure(let error): // TODO: handle error @@ -102,9 +102,10 @@ extension HomeTimelineViewModel.LoadLatestState { if newToots.isEmpty { viewModel.isFetchingLatestTimeline.value = false - viewModel.homeTimelineNavigationBarState.newTopContent.value = false } else { - viewModel.homeTimelineNavigationBarState.newTopContent.value = true + if !latestTootIDs.isEmpty { + viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() + } } } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift index bb1211d2f..07b6abf17 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift @@ -68,7 +68,7 @@ extension HomeTimelineViewModel.LoadMiddleState { .delay(for: .seconds(1), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) .sink { completion in - viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) + viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) switch completion { case .failure(let error): // TODO: handle error diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index b18a66c01..341183dcf 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -58,7 +58,7 @@ extension HomeTimelineViewModel.LoadOldestState { .delay(for: .seconds(1), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) .sink { completion in - viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) + viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) switch completion { case .failure(let error): os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 263e76a9d..7b9c35308 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -28,17 +28,11 @@ final class HomeTimelineViewModel: NSObject { let fetchedResultsController: NSFetchedResultsController let isFetchingLatestTimeline = CurrentValueSubject(false) let viewDidAppear = PassthroughSubject() - - let homeTimelineNavigationBarState = HomeTimelineNavigationBarState() + let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? weak var tableView: UITableView? weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? - weak var viewController: HomeTimelineViewController? { - willSet(value) { - self.homeTimelineNavigationBarState.viewController = value - } - } // output // top loader @@ -90,6 +84,7 @@ final class HomeTimelineViewModel: NSObject { return controller }() + self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context) super.init() fetchedResultsController.delegate = self diff --git a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift new file mode 100644 index 000000000..604c0915d --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift @@ -0,0 +1,210 @@ +// +// HomeTimelineNavigationBarTitleView.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/15. +// + +import os.log +import UIKit + +protocol HomeTimelineNavigationBarTitleViewDelegate: class { + func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) +} + +final class HomeTimelineNavigationBarTitleView: UIView { + + let containerView = UIStackView() + + let imageView = UIImageView() + let button = RoundedEdgesButton() + let label = UILabel() + + // input + private var blockingState: HomeTimelineNavigationBarTitleViewModel.State? + weak var delegate: HomeTimelineNavigationBarTitleViewDelegate? + + // output + private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logoImage + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension HomeTimelineNavigationBarTitleView { + private func _init() { + containerView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerView) + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: topAnchor), + containerView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + containerView.addArrangedSubview(imageView) + button.translatesAutoresizingMaskIntoConstraints = false + containerView.addArrangedSubview(button) + NSLayoutConstraint.activate([ + button.heightAnchor.constraint(equalToConstant: 24).priority(.defaultHigh) + ]) + containerView.addArrangedSubview(label) + + configure(state: .logoImage) + button.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.buttonDidPressed(_:)), for: .touchUpInside) + } +} + +extension HomeTimelineNavigationBarTitleView { + @objc private func buttonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.homeTimelineNavigationBarTitleView(self, buttonDidPressed: sender) + } +} + +extension HomeTimelineNavigationBarTitleView { + + func resetContainer() { + imageView.isHidden = true + button.isHidden = true + label.isHidden = true + } + + func configure(state: HomeTimelineNavigationBarTitleViewModel.State) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: configure title view: %s", ((#file as NSString).lastPathComponent), #line, #function, state.rawValue) + self.state = state + + // check state block or not + guard blockingState == nil else { + return + } + + resetContainer() + + switch state { + case .logoImage: + imageView.tintColor = Asset.Colors.Label.primary.color + imageView.image = Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate) + imageView.contentMode = .center + imageView.isHidden = false + case .newPostButton: + configureButton( + title: L10n.Scene.HomeTimeline.NavigationBarState.newPosts, + textColor: .white, + backgroundColor: Asset.Colors.Button.normal.color + ) + button.isHidden = false + case .offlineButton: + configureButton( + title: L10n.Scene.HomeTimeline.NavigationBarState.offline, + textColor: .white, + backgroundColor: Asset.Colors.Background.danger.color + ) + button.isHidden = false + case .publishingPostLabel: + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.textColor = Asset.Colors.Label.primary.color + label.text = L10n.Scene.HomeTimeline.NavigationBarState.publishing + label.textAlignment = .center + label.isHidden = false + case .publishedButton: + blockingState = state + configureButton( + title: L10n.Scene.HomeTimeline.NavigationBarState.published, + textColor: .white, + backgroundColor: Asset.Colors.Background.success.color + ) + button.isHidden = false + + let presentDuration: TimeInterval = 0.33 + let scaleAnimator = UIViewPropertyAnimator(duration: presentDuration, timingParameters: UISpringTimingParameters()) + button.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) + scaleAnimator.addAnimations { + self.button.transform = .identity + } + let alphaAnimator = UIViewPropertyAnimator(duration: presentDuration, curve: .easeInOut) + button.alpha = 0.3 + alphaAnimator.addAnimations { + self.button.alpha = 1 + } + scaleAnimator.startAnimation() + alphaAnimator.startAnimation() + + let dismissDuration: TimeInterval = 3 + let dissolveAnimator = UIViewPropertyAnimator(duration: dismissDuration, curve: .easeInOut) + dissolveAnimator.addAnimations({ + self.button.alpha = 0 + }, delayFactor: 0.9) // at 2.7s + dissolveAnimator.addCompletion { _ in + self.blockingState = nil + self.configure(state: self.state) + self.button.alpha = 1 + } + dissolveAnimator.startAnimation() + } + } + + private func configureButton(title: String, textColor: UIColor, backgroundColor: UIColor) { + button.setBackgroundImage(.placeholder(color: backgroundColor), for: .normal) + button.setBackgroundImage(.placeholder(color: backgroundColor.withAlphaComponent(0.5)), for: .highlighted) + button.setTitleColor(textColor, for: .normal) + button.setTitleColor(textColor.withAlphaComponent(0.5), for: .highlighted) + button.setTitle(title, for: .normal) + button.contentEdgeInsets = UIEdgeInsets(top: 1, left: 16, bottom: 1, right: 16) + button.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold) + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct HomeTimelineNavigationBarTitleView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let titleView = HomeTimelineNavigationBarTitleView() + titleView.configure(state: .logoImage) + return titleView + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 150) { + let titleView = HomeTimelineNavigationBarTitleView() + titleView.configure(state: .newPostButton) + return titleView + } + .previewLayout(.fixed(width: 150, height: 24)) + UIViewPreview(width: 120) { + let titleView = HomeTimelineNavigationBarTitleView() + titleView.configure(state: .offlineButton) + return titleView + } + .previewLayout(.fixed(width: 120, height: 24)) + UIViewPreview(width: 375) { + let titleView = HomeTimelineNavigationBarTitleView() + titleView.configure(state: .publishingPostLabel) + return titleView + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 120) { + let titleView = HomeTimelineNavigationBarTitleView() + titleView.configure(state: .publishedButton) + return titleView + } + .previewLayout(.fixed(width: 120, height: 24)) + } + } + +} + +#endif + diff --git a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift new file mode 100644 index 000000000..e1fc3174e --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift @@ -0,0 +1,174 @@ +// +// HomeTimelineNavigationBarTitleViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/15. +// + +import Combine +import Foundation +import UIKit + +final class HomeTimelineNavigationBarTitleViewModel { + + static let offlineCounterLimit = 3 + + var disposeBag = Set() + private(set) var publishingProgressSubscription: AnyCancellable? + + // input + let context: AppContext + var networkErrorCount = CurrentValueSubject(0) + var networkErrorPublisher = PassthroughSubject() + + // output + let state = CurrentValueSubject(.logoImage) + let hasNewPosts = CurrentValueSubject(false) + let isOffline = CurrentValueSubject(false) + let isPublishingPost = CurrentValueSubject(false) + let isPublished = CurrentValueSubject(false) + let publishingProgress = PassthroughSubject() + + init(context: AppContext) { + self.context = context + + networkErrorPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.networkErrorCount.value += self.networkErrorCount.value + 1 + } + .store(in: &disposeBag) + + networkErrorCount + .receive(on: DispatchQueue.main) + .map { count in + return count >= HomeTimelineNavigationBarTitleViewModel.offlineCounterLimit + } + .assign(to: \.value, on: isOffline) + .store(in: &disposeBag) + + context.statusPublishService.latestPublishingComposeViewModel + .receive(on: DispatchQueue.main) + .sink { [weak self] composeViewModel in + guard let self = self else { return } + guard let composeViewModel = composeViewModel, + let state = composeViewModel.publishStateMachine.currentState else { + self.isPublishingPost.value = false + self.isPublished.value = false + return + } + + self.isPublishingPost.value = state is ComposeViewModel.PublishState.Publishing || state is ComposeViewModel.PublishState.Fail + self.isPublished.value = state is ComposeViewModel.PublishState.Finish + } + .store(in: &disposeBag) + + Publishers.CombineLatest4( + hasNewPosts.eraseToAnyPublisher(), + isOffline.eraseToAnyPublisher(), + isPublishingPost.eraseToAnyPublisher(), + isPublished.eraseToAnyPublisher() + ) + .map { hasNewPosts, isOffline, isPublishingPost, isPublished -> State in + guard !isPublished else { return .publishedButton } + guard !isPublishingPost else { return .publishingPostLabel } + guard !isOffline else { return .offlineButton } + guard !hasNewPosts else { return .newPostButton } + return .logoImage + } + .receive(on: DispatchQueue.main) + .assign(to: \.value, on: state) + .store(in: &disposeBag) + + state + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + switch state { + case .publishingPostLabel: + self.setupPublishingProgress() + default: + self.suspendPublishingProgress() + } + } + .store(in: &disposeBag) + } +} + +extension HomeTimelineNavigationBarTitleViewModel { + // state order by priority from low to high + enum State: String { + case logoImage + case newPostButton + case offlineButton + case publishingPostLabel + case publishedButton + } +} + +// MARK: - New post state +extension HomeTimelineNavigationBarTitleViewModel { + + func newPostsIncoming() { + hasNewPosts.value = true + } + + private func resetNewPostState() { + hasNewPosts.value = false + } + +} + +// MARK: - Offline state +extension HomeTimelineNavigationBarTitleViewModel { + + func resetOfflineCounterListener() { + networkErrorCount.value = 0 + } + + func receiveLoadingStateCompletion(_ completion: Subscribers.Completion) { + switch completion { + case .failure: + networkErrorPublisher.send() + case .finished: + resetOfflineCounterListener() + } + } + + func handleScrollViewDidScroll(_ scrollView: UIScrollView) { + guard hasNewPosts.value else { return } + + let contentOffsetY = scrollView.contentOffset.y + let isScrollToTop = contentOffsetY < -scrollView.contentInset.top + guard isScrollToTop else { return } + resetNewPostState() + } + +} + +// MARK: Publish post state +extension HomeTimelineNavigationBarTitleViewModel { + + func setupPublishingProgress() { + let progressUpdatePublisher = Timer.publish(every: 0.016, on: .main, in: .common) // ~ 60FPS + .autoconnect() + .share() + .eraseToAnyPublisher() + + publishingProgressSubscription = progressUpdatePublisher + .map { _ in Float(0) } + .scan(0.0) { progress, _ -> Float in + return 0.95 * progress + 0.05 // progress + 0.05 * (1.0 - progress). ~ 1 sec to 0.95 (under 60FPS) + } + .subscribe(publishingProgress) + } + + func suspendPublishingProgress() { + publishingProgressSubscription = nil + publishingProgress.send(0) + } + +} + diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index a556854e5..72f62528a 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -123,6 +123,32 @@ extension MainTabBarController { } } .store(in: &disposeBag) + + // handle post failure + context.statusPublishService + .latestPublishingComposeViewModel + .receive(on: DispatchQueue.main) + .sink { [weak self] composeViewModel in + guard let self = self else { return } + guard let composeViewModel = composeViewModel else { return } + guard let currentState = composeViewModel.publishStateMachine.currentState else { return } + guard currentState is ComposeViewModel.PublishState.Fail else { return } + + let alertController = UIAlertController(title: L10n.Common.Alerts.PublishPostFailure.title, message: L10n.Common.Alerts.PublishPostFailure.message, preferredStyle: .alert) + let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self, weak composeViewModel] _ in + guard let self = self else { return } + guard let composeViewModel = composeViewModel else { return } + self.context.statusPublishService.remove(composeViewModel: composeViewModel) + } + alertController.addAction(discardAction) + let retryAction = UIAlertAction(title: L10n.Common.Controls.Actions.tryAgain, style: .default) { [weak composeViewModel] _ in + guard let composeViewModel = composeViewModel else { return } + composeViewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) + } + alertController.addAction(retryAction) + self.present(alertController, animated: true, completion: nil) + } + .store(in: &disposeBag) #if DEBUG // selectedIndex = 1 diff --git a/Mastodon/Service/StatusPrefetchingService.swift b/Mastodon/Service/StatusPrefetchingService.swift index d4332fe16..5d0191ff3 100644 --- a/Mastodon/Service/StatusPrefetchingService.swift +++ b/Mastodon/Service/StatusPrefetchingService.swift @@ -16,8 +16,8 @@ final class StatusPrefetchingService { typealias TaskID = String - let workingQueue = DispatchQueue(label: "status-prefetching-service-working-queue") - + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.StatusPrefetchingService.working-queue") + var disposeBag = Set() private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:] diff --git a/Mastodon/Service/StatusPublishService.swift b/Mastodon/Service/StatusPublishService.swift new file mode 100644 index 000000000..4728af8c1 --- /dev/null +++ b/Mastodon/Service/StatusPublishService.swift @@ -0,0 +1,78 @@ +// +// StatusPublishService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-26. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class StatusPublishService { + + var disposeBag = Set() + + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.StatusPublishService.working-queue") + + // input + var viewModels = CurrentValueSubject<[ComposeViewModel], Never>([]) // use strong reference to retain the view models + + // output + let composeViewModelDidUpdatePublisher = PassthroughSubject() + let latestPublishingComposeViewModel = CurrentValueSubject(nil) + + init() { + Publishers.CombineLatest( + viewModels.eraseToAnyPublisher(), + composeViewModelDidUpdatePublisher.eraseToAnyPublisher() + ) + .map { viewModels, _ in viewModels.last } + .assign(to: \.value, on: latestPublishingComposeViewModel) + .store(in: &disposeBag) + } + +} + +extension StatusPublishService { + + func publish(composeViewModel: ComposeViewModel) { + workingQueue.sync { + guard !self.viewModels.value.contains(where: { $0 === composeViewModel }) else { return } + self.viewModels.value = self.viewModels.value + [composeViewModel] + + composeViewModel.publishStateMachinePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self, weak composeViewModel] state in + guard let self = self else { return } + guard let composeViewModel = composeViewModel else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: composeViewModelDidUpdate", ((#file as NSString).lastPathComponent), #line, #function) + self.composeViewModelDidUpdatePublisher.send() + + switch state { + case is ComposeViewModel.PublishState.Finish: + self.remove(composeViewModel: composeViewModel) + default: + break + } + } + .store(in: &composeViewModel.disposeBag) // cancel subscription when viewModel dealloc + } + } + + func remove(composeViewModel: ComposeViewModel) { + workingQueue.async { + var viewModels = self.viewModels.value + viewModels.removeAll(where: { $0 === composeViewModel }) + self.viewModels.value = viewModels + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: composeViewModel removed", ((#file as NSString).lastPathComponent), #line, #function) + + } + } + +} diff --git a/Mastodon/Service/ViedeoPlaybackService.swift b/Mastodon/Service/ViedeoPlaybackService.swift index 24ea6e6ce..15348c6e9 100644 --- a/Mastodon/Service/ViedeoPlaybackService.swift +++ b/Mastodon/Service/ViedeoPlaybackService.swift @@ -14,7 +14,7 @@ import os.log final class VideoPlaybackService { var disposeBag = Set() - let workingQueue = DispatchQueue(label: "com.twidere.twiderex.video-playback-service.working-queue") + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.VideoPlaybackService.working-queue") private(set) var viewPlayerViewModelDict: [URL: VideoPlayerViewModel] = [:] // only for video kind diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 28325f94e..903cb7693 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -27,6 +27,7 @@ class AppContext: ObservableObject { let audioPlaybackService = AudioPlaybackService() let videoPlaybackService = VideoPlaybackService() let statusPrefetchingService: StatusPrefetchingService + let statusPublishService = StatusPublishService() let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable!