diff --git a/Localization/app.json b/Localization/app.json index 4c79a97b..6170c000 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -169,10 +169,16 @@ } }, "home_timeline": { - "title": "Home" + "title": "Home", + "navigation_bar_state": { + "offline": "Offline", + "new_posts": "See new posts", + "published": "Published!", + "Publishing": "Publishing post..." + }, }, "public_timeline": { "title": "Public" } } -} \ No newline at end of file +} diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9859676c..8b75de62 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -72,6 +72,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 */; }; 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0125C7E4F2004F19B8 /* Mention.swift */; }; 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; }; 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; }; @@ -318,6 +320,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 = ""; }; 2D927F0125C7E4F2004F19B8 /* Mention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = ""; }; 2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; 2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = ""; }; @@ -612,6 +616,8 @@ 2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */, 2D38F1F025CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift */, 2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */, + 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */, + 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */, ); path = HomeTimeline; sourceTree = ""; @@ -1603,7 +1609,9 @@ 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, + 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, + 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 399d1a5e..166a6122 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -162,6 +162,16 @@ internal enum L10n { internal enum HomeTimeline { /// Home internal static let title = L10n.tr("Localizable", "Scene.HomeTimeline.Title") + internal enum NavigationBarState { + /// See new posts + internal static let newPosts = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.NewPosts") + /// Offline + internal static let offline = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Offline") + /// Published! + internal static let published = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Published") + /// Publishing post... + internal static let publishing = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Publishing") + } } internal enum PublicTimeline { /// Public diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index c79e3d0c..2a72b92c 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -46,6 +46,10 @@ "Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@, tap the link to confirm your account."; "Scene.ConfirmEmail.Title" = "One last thing."; +"Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts"; +"Scene.HomeTimeline.NavigationBarState.Offline" = "Offline"; +"Scene.HomeTimeline.NavigationBarState.Published" = "Published!"; +"Scene.HomeTimeline.NavigationBarState.Publishing" = "Publishing post..."; "Scene.HomeTimeline.Title" = "Home"; "Scene.PublicTimeline.Title" = "Public"; "Scene.Register.Error.Item.Agreement" = "Agreement"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift new file mode 100644 index 00000000..41be16f8 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift @@ -0,0 +1,115 @@ +// +// 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 networkErrorCountSubject = PassthroughSubject() + + var titleViewBeforePublishing: UIView? // used for restore titleView after published + + var newTopContent = CurrentValueSubject(false) + var newBottomContent = CurrentValueSubject(false) + var hasContentBeforeFetching: Bool = true + + weak var viewController: HomeTimelineViewController? + + init() { + reCountdown() + subscribeNewContent() + } +} + +extension HomeTimelineNavigationBarState { + func showOfflineInNavigationBar() { + viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.offlineView + } + + func showNewPostsInNavigationBar() { + viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.newPostsView + } + + func showPublishingNewPostInNavigationBar() { + titleViewBeforePublishing = viewController?.navigationItem.titleView + } + + func showPublishedInNavigationBar() { + viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.publishedView + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) { + if let titleView = self.titleViewBeforePublishing, let navigationItem = self.viewController?.navigationItem { + navigationItem.titleView = titleView + } + } + } + + func showMastodonLogoInNavigationBar() { + viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.mastodonLogoTitleView + } +} + +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) + newBottomContent + .receive(on: DispatchQueue.main) + .sink { [weak self] newContent in + guard let self = self else { return } + if 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 handleScrollViewDidScroll(_ scrollView: UIScrollView) { + let contentOffsetY = scrollView.contentOffset.y + print(contentOffsetY) + let isTop = contentOffsetY < -scrollView.contentInset.top + if isTop { + newTopContent.value = false + showMastodonLogoInNavigationBar() + } + let isBottom = contentOffsetY > max(-scrollView.contentInset.top, scrollView.contentSize.height - scrollView.frame.height + scrollView.contentInset.bottom) + if isBottom { + newBottomContent.value = false + showMastodonLogoInNavigationBar() + } + } + + func receiveCompletion(completion: Subscribers.Completion) { + switch completion { + case .failure: + networkErrorCountSubject.send(false) + case .finished: + reCountdown() + } + } +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift new file mode 100644 index 00000000..b8906ab0 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift @@ -0,0 +1,70 @@ +// +// 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.lightDangerRed.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.highlight.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 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) + ]) + label.sizeToFit() + view.layoutIfNeeded() + view.layer.cornerRadius = view.frame.height/2 + view.clipsToBounds = true + } + + static func backgroundViewWithColor(color:UIColor) -> UIView { + let view = UIView() + view.backgroundColor = color + view.translatesAutoresizingMaskIntoConstraints = false + 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 9db551f6..fe273241 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -64,11 +64,7 @@ extension HomeTimelineViewController { title = L10n.Scene.HomeTimeline.title view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - navigationItem.titleView = { - let imageView = UIImageView(image: Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate)) - imageView.tintColor = Asset.Colors.Label.primary.color - return imageView - }() + navigationItem.titleView = HomeTimelineNavigationBarView.mastodonLogoTitleView navigationItem.leftBarButtonItem = settingBarButtonItem #if DEBUG // long press to trigger debug menu @@ -101,6 +97,7 @@ extension HomeTimelineViewController { ]) viewModel.tableView = tableView + viewModel.viewController = self viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self tableView.delegate = self viewModel.setupDiffableDataSource( @@ -208,6 +205,7 @@ extension HomeTimelineViewController { extension HomeTimelineViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { handleScrollViewDidScroll(scrollView) + self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView) } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index c085471c..0df4334a 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -73,6 +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) @@ -80,6 +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) switch completion { case .failure(let error): // TODO: handle error @@ -97,9 +99,12 @@ extension HomeTimelineViewModel.LoadLatestState { let toots = response.value let newToots = toots.filter { !latestTootIDs.contains($0.id) } os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld new toots", ((#file as NSString).lastPathComponent), #line, #function, newToots.count) - + if newToots.isEmpty { viewModel.isFetchingLatestTimeline.value = false + viewModel.homeTimelineNavigationBarState.newTopContent.value = false + } else { + viewModel.homeTimelineNavigationBarState.newTopContent.value = true } } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift index 5a212f35..7b7f3a70 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift @@ -68,6 +68,7 @@ extension HomeTimelineViewModel.LoadMiddleState { .delay(for: .seconds(1), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) .sink { completion in + viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) switch completion { case .failure(let error): // TODO: handle error @@ -82,8 +83,10 @@ extension HomeTimelineViewModel.LoadMiddleState { os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld toots, %{public}%ld new toots", ((#file as NSString).lastPathComponent), #line, #function, toots.count, newToots.count) if newToots.isEmpty { stateMachine.enter(Fail.self) + viewModel.homeTimelineNavigationBarState.newTopContent.value = false } else { stateMachine.enter(Success.self) + viewModel.homeTimelineNavigationBarState.newTopContent.value = true } } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index c6eb988b..5bca33bd 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -70,8 +70,10 @@ extension HomeTimelineViewModel.LoadOldestState { // enter no more state when no new toots if toots.isEmpty || (toots.count == 1 && toots[0].id == maxID) { stateMachine.enter(NoMore.self) + viewModel.homeTimelineNavigationBarState.newBottomContent.value = false } else { stateMachine.enter(Idle.self) + viewModel.homeTimelineNavigationBarState.newBottomContent.value = true } } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 44457839..263e76a9 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -29,9 +29,16 @@ final class HomeTimelineViewModel: NSObject { let isFetchingLatestTimeline = CurrentValueSubject(false) let viewDidAppear = PassthroughSubject() + let homeTimelineNavigationBarState = HomeTimelineNavigationBarState() + 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