diff --git a/Localization/StringsConvertor/input/Base.lproj/app.json b/Localization/StringsConvertor/input/Base.lproj/app.json index 8658e52d8..261d40e95 100644 --- a/Localization/StringsConvertor/input/Base.lproj/app.json +++ b/Localization/StringsConvertor/input/Base.lproj/app.json @@ -474,15 +474,10 @@ "following": "Following", "local_community": "Local" }, - "navigation_bar_state": { + "timeline_pill": { "offline": "Offline", - "new_posts": "See new posts", - "published": "Published!", - "Publishing": "Publishing post...", - "accessibility": { - "logo_label": "Mastodon", - "logo_hint": "Tap to scroll to top and tap again to previous location" - } + "new_posts": "New Posts", + "post_sent": "Post Sent" } }, "suggestion_account": { diff --git a/Localization/app.json b/Localization/app.json index 8658e52d8..261d40e95 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -474,15 +474,10 @@ "following": "Following", "local_community": "Local" }, - "navigation_bar_state": { + "timeline_pill": { "offline": "Offline", - "new_posts": "See new posts", - "published": "Published!", - "Publishing": "Publishing post...", - "accessibility": { - "logo_label": "Mastodon", - "logo_hint": "Tap to scroll to top and tap again to previous location" - } + "new_posts": "New Posts", + "post_sent": "Post Sent" } }, "suggestion_account": { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d4139d7a9..9c96d3929 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -91,8 +91,6 @@ 2D7867192625B77500211898 /* NotificationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7867182625B77500211898 /* NotificationItem.swift */; }; 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */; }; 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.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 */; }; 2D939AB525EDD8A90076FA61 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */; }; @@ -154,6 +152,7 @@ D8318A882A4468D300C0FB73 /* NotificationSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8318A872A4468D300C0FB73 /* NotificationSettingsViewController.swift */; }; D8318A8A2A4468DC00C0FB73 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8318A892A4468DC00C0FB73 /* AboutViewController.swift */; }; D8363B1629469CE200A74079 /* OnboardingNextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8363B1529469CE200A74079 /* OnboardingNextView.swift */; }; + D84738D42BBD9ABE00ECD52B /* TimelineStatusPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = D84738D32BBD9ABE00ECD52B /* TimelineStatusPill.swift */; }; D84FA0932AE6915800987F47 /* MBProgressHUD in Frameworks */ = {isa = PBXBuildFile; productRef = D84FA0922AE6915800987F47 /* MBProgressHUD */; }; D852C23C2AC5D02C00309232 /* AboutInstanceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D852C23B2AC5D02C00309232 /* AboutInstanceViewController.swift */; }; D852C23E2AC5D03300309232 /* InstanceRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D852C23D2AC5D03300309232 /* InstanceRulesViewController.swift */; }; @@ -715,8 +714,6 @@ 2D7867182625B77500211898 /* NotificationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItem.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 /* 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 = ""; }; 2D939AB425EDD8A90076FA61 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewController.swift; sourceTree = ""; }; @@ -779,6 +776,7 @@ D8318A872A4468D300C0FB73 /* NotificationSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsViewController.swift; sourceTree = ""; }; D8318A892A4468DC00C0FB73 /* AboutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = ""; }; D8363B1529469CE200A74079 /* OnboardingNextView.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = OnboardingNextView.swift; sourceTree = ""; tabWidth = 4; }; + D84738D32BBD9ABE00ECD52B /* TimelineStatusPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusPill.swift; sourceTree = ""; }; D84C099D2B0F9E33009E685E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; D84C099F2B0F9E41009E685E /* Setup.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Setup.md; sourceTree = ""; }; D84C09A02B0F9E41009E685E /* How-it-works.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "How-it-works.md"; sourceTree = ""; }; @@ -1501,13 +1499,13 @@ 2D38F1D325CD463600561493 /* HomeTimeline */ = { isa = PBXGroup; children = ( - DB1F239626117C360057430E /* View */, 2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */, DB697DD8278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift */, 2D38F1E425CD46C100561493 /* HomeTimelineViewModel.swift */, 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */, 2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */, 2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */, + D84738D32BBD9ABE00ECD52B /* TimelineStatusPill.swift */, ); path = HomeTimeline; sourceTree = ""; @@ -1983,15 +1981,6 @@ path = TableView; sourceTree = ""; }; - DB1F239626117C360057430E /* View */ = { - isa = PBXGroup; - children = ( - 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */, - 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */, - ); - path = View; - sourceTree = ""; - }; DB3D0FF725BAA68500EAA174 /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -3484,7 +3473,6 @@ DB0617FF27855D6C0030EE79 /* MastodonServerRulesViewModel+Diffable.swift in Sources */, DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */, DB0FCB982797F6BF006C02E2 /* UserTableViewCell+ViewModel.swift in Sources */, - 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, DB697DD6278F4C29004EF2F7 /* DataSourceProvider.swift in Sources */, DB0FCB8E2796C0B7006C02E2 /* TrendCollectionViewCell.swift in Sources */, 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */, @@ -3494,7 +3482,6 @@ DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, D8FAAE432AD047B200DC1832 /* AboutInstanceTableFooterView.swift in Sources */, D808B94E296EFBBA0031EB1E /* StatusEditHistoryTableViewCell.swift in Sources */, - 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, D852C23E2AC5D03300309232 /* InstanceRulesViewController.swift in Sources */, DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */, DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */, @@ -3763,6 +3750,7 @@ DB6B74FC272FF55800C70B6E /* UserSection.swift in Sources */, DB0FCB862796BDA1006C02E2 /* SearchSection.swift in Sources */, DB1D61CF26F1B33600DA8662 /* WelcomeViewModel.swift in Sources */, + D84738D42BBD9ABE00ECD52B /* TimelineStatusPill.swift in Sources */, D8B5E4F42A4ED0240008970C /* NotificationSettingsViewModel.swift in Sources */, DBD376B2269302A4007FEC24 /* UITableViewCell.swift in Sources */, DB4F0966269ED52200D62E92 /* SearchResultViewModel.swift in Sources */, diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 100b41300..8559025d4 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -44,8 +44,6 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media return emptyView }() - let titleView = HomeTimelineNavigationBarTitleView() - lazy var timelineSelectorButton = { let button = UIButton(type: .custom) button.setAttributedTitle( @@ -101,6 +99,11 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media }() let refreshControl = RefreshControl() + let timelinePill = TimelineStatusPill() + var timelinePillCenterXAnchor: NSLayoutConstraint? + var timelinePillVisibleTopAnchor: NSLayoutConstraint? + var timelinePillHiddenTopAnchor: NSLayoutConstraint? + private func generateTimeSelectorMenu() -> UIMenu { let showFollowingAction = UIAction(title: L10n.Scene.HomeTimeline.TimelineMenu.following, image: .init(systemName: "house")) { [weak self] _ in @@ -170,33 +173,6 @@ extension HomeTimelineViewController { settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) self.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: timelineSelectorButton) - -// navigationItem.titleView = titleView -// titleView.delegate = self - - viewModel?.homeTimelineNavigationBarTitleViewModel.state - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] state in - guard let self = self else { return } - self.titleView.configure(state: state) - } - .store(in: &disposeBag) - - viewModel?.homeTimelineNavigationBarTitleViewModel.state - .removeDuplicates() - .filter { $0 == .publishedButton } - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - guard UserDefaults.shared.lastVersionPromptedForReview == nil else { return } - guard UserDefaults.shared.processCompletedCount > 3 else { return } - guard let windowScene = self.view.window?.windowScene else { return } - let version = UIApplication.appVersion() - UserDefaults.shared.lastVersionPromptedForReview = version - SKStoreReviewController.requestReview(in: windowScene) - } - .store(in: &disposeBag) tableView.refreshControl = refreshControl refreshControl.addTarget(self, action: #selector(HomeTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged) @@ -326,8 +302,68 @@ extension HomeTimelineViewController { } .store(in: &disposeBag) + timelinePill.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(timelinePill) + + let timelinePillCenterXAnchor = timelinePill.centerXAnchor.constraint(equalTo: view.centerXAnchor) + let timelinePillVisibleTopAnchor = timelinePill.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8) + let timelinePillHiddenTopAnchor = view.safeAreaLayoutGuide.topAnchor.constraint(equalTo: timelinePill.bottomAnchor, constant: 8) + + NSLayoutConstraint.activate([ + timelinePillHiddenTopAnchor, timelinePillCenterXAnchor + ]) + + timelinePill.addTarget(self, action: #selector(HomeTimelineViewController.timelinePillTouched(_:)), for: .touchDown) + timelinePill.addTarget(self, action: #selector(HomeTimelineViewController.timelinePillPressedInside(_:)), for: .touchUpInside) + timelinePill.addTarget(self, action: #selector(HomeTimelineViewController.timelinePillTouchedOutside(_:)), for: .touchUpOutside) + + self.timelinePillCenterXAnchor = timelinePillCenterXAnchor + self.timelinePillVisibleTopAnchor = timelinePillVisibleTopAnchor + self.timelinePillHiddenTopAnchor = timelinePillHiddenTopAnchor + + viewModel?.hasNewPosts + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] hasNewPosts in + guard let self else { return } + + if hasNewPosts { + self.timelinePill.update(with: .newPosts) + self.showTimelinePill() + } else { + self.hideTimelinePill() + } + }) + .store(in: &disposeBag) + + viewModel?.isOffline + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] isOffline in + guard let self else { return } + + if isOffline { + self.timelinePill.update(with: .offline) + self.showTimelinePill() + } else { + self.hideTimelinePill() + } + }) + .store(in: &disposeBag) + + context.publisherService.statusPublishResult.prepend(.failure(AppError.badRequest)) + .receive(on: DispatchQueue.main) + .sink { [weak self] publishResult in + guard let self else { return } + switch publishResult { + case .success: + self.timelinePill.update(with: .postSent) + self.showTimelinePill() + case .failure: + self.hideTimelinePill() + } + } + .store(in: &disposeBag) } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -477,18 +513,67 @@ extension HomeTimelineViewController { } } + @objc private func timelinePillTouched(_ sender: TimelineStatusPill) { + UIView.animate(withDuration: 0.05) { + sender.transform = CGAffineTransform.identity.scaledBy(x: 0.95, y: 0.95) + } + } + + @objc private func timelinePillTouchedOutside(_ sender: TimelineStatusPill) { + UIView.animate(withDuration: 0.05) { + sender.transform = CGAffineTransform.identity.scaledBy(x: 100/95.0, y: 100/95.0) + } + } + + @objc private func timelinePillPressedInside(_ sender: TimelineStatusPill) { + guard let reason = sender.reason else { return } + + UIView.animate(withDuration: 0.05) { + sender.transform = CGAffineTransform.identity.scaledBy(x: 100/95.0, y: 100/95.0) + } + + switch reason { + case .newPosts: + scrollToTop(animated: true) + viewModel?.hasNewPosts.value = false + case .postSent: + scrollToTop(animated: true) + hideTimelinePill() + case .offline: + hideTimelinePill() + } + } + + private func showTimelinePill() { + guard let timelinePillHiddenTopAnchor, let timelinePillVisibleTopAnchor else { return } + + timelinePill.setNeedsLayout() + timelinePill.layoutIfNeeded() + timelinePill.alpha = 0 + NSLayoutConstraint.deactivate([timelinePillHiddenTopAnchor]) + NSLayoutConstraint.activate([timelinePillVisibleTopAnchor]) + + UIView.animate(withDuration: 0.5, delay: 0.0, usingSpringWithDamping: 0.75, initialSpringVelocity: 0.9) { [weak self] in + self?.timelinePill.alpha = 1 + self?.view.layoutIfNeeded() + } + } + + private func hideTimelinePill() { + guard let timelinePillHiddenTopAnchor, let timelinePillVisibleTopAnchor else { return } + + NSLayoutConstraint.deactivate([timelinePillVisibleTopAnchor]) + NSLayoutConstraint.activate([timelinePillHiddenTopAnchor]) + timelinePill.alpha = 1 + UIView.animate(withDuration: 0.5, animations: { [weak self] in + self?.timelinePill.alpha = 0 + self?.view.layoutIfNeeded() + }) + } + } // MARK: - UIScrollViewDelegate extension HomeTimelineViewController { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - switch scrollView { - case tableView: - viewModel?.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) - default: - break - } - } - func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { switch scrollView { case tableView: @@ -644,37 +729,6 @@ extension HomeTimelineViewController: ScrollViewContainer { // MARK: - StatusTableViewCellDelegate extension HomeTimelineViewController: StatusTableViewCellDelegate { } -// MARK: - HomeTimelineNavigationBarTitleViewDelegate -extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate { - func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, logoButtonDidPressed sender: UIButton) { - if shouldRestoreScrollPosition() { - restorePositionWhenScrollToTop() - } else { - savePositionBeforeScrollToTop() - scrollToTop(animated: true) - } - } - - func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) { - switch titleView.state { - case .newPostButton: - guard let diffableDataSource = viewModel?.diffableDataSource else { return } - let indexPath = IndexPath(row: 0, section: 0) - guard diffableDataSource.itemIdentifier(for: indexPath) != nil else { return } - - savePositionBeforeScrollToTop() - tableView.scrollToRow(at: indexPath, at: .top, animated: true) - case .offlineButton: - // TODO: retry - break - case .publishedButton: - break - default: - break - } - } -} - extension HomeTimelineViewController { override var keyCommands: [UIKeyCommand]? { return navigationKeyCommands + statusNavigationKeyCommands diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index ae57f947f..dddae7a85 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -129,7 +129,7 @@ extension HomeTimelineViewModel.LoadLatestState { } await enter(state: Idle.self) - viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.finished) + viewModel.receiveLoadingStateCompletion(.finished) // stop refresher if no new statuses let statuses = response.value @@ -137,11 +137,7 @@ extension HomeTimelineViewModel.LoadLatestState { if newStatuses.isEmpty { viewModel.didLoadLatest.send() - } else { - if !latestStatusIDs.isEmpty { - viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() - } - + } else { viewModel.dataController.records = { var newRecords: [MastodonFeed] = newStatuses.map { MastodonFeed.fromStatus(.fromEntity($0), kind: .home) @@ -163,11 +159,15 @@ extension HomeTimelineViewModel.LoadLatestState { if !isUserInitiated { FeedbackGenerator.shared.generate(.impact(.light)) } - + + if newStatuses.isNotEmpty { + viewModel.hasNewPosts.value = true + } + } catch { await enter(state: Idle.self) viewModel.didLoadLatest.send() - viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.failure(error)) + viewModel.receiveLoadingStateCompletion(.failure(error)) } } // end Task } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index 668095b24..565e7c8b0 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -83,12 +83,12 @@ extension HomeTimelineViewModel.LoadOldestState { } else { await self.enter(state: Idle.self) } - - viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.finished) - + + viewModel.receiveLoadingStateCompletion(.finished) + } catch { await self.enter(state: Fail.self) - viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(.failure(error)) + viewModel.receiveLoadingStateCompletion(.failure(error)) } } // end Task } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index a67d44f8d..e19913873 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -25,7 +25,6 @@ final class HomeTimelineViewModel: NSObject { let context: AppContext let authContext: AuthContext let dataController: FeedDataController - let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel let listBatchFetchViewModel = ListBatchFetchViewModel() var presentedSuggestions = false @@ -34,6 +33,12 @@ final class HomeTimelineViewModel: NSObject { @Published var scrollPositionRecord: ScrollPositionRecord? = nil @Published var displaySettingBarButtonItem = true @Published var hasPendingStatusEditReload = false + let hasNewPosts = CurrentValueSubject(false) + + /// Becomes `true` if `networkErrorCount` is bigger than 5 + let isOffline = CurrentValueSubject(false) + var networkErrorCount = CurrentValueSubject(0) + var timelineContext: MastodonFeed.Kind.TimelineContext = .home weak var tableView: UITableView? @@ -81,7 +86,6 @@ final class HomeTimelineViewModel: NSObject { self.context = context self.authContext = authContext self.dataController = FeedDataController(context: context, authContext: authContext) - self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context) super.init() self.dataController.records = (try? FileManager.default.cachedHomeTimeline(for: authContext.mastodonAuthenticationBox).map { MastodonFeed.fromStatus($0, kind: .home) @@ -92,16 +96,6 @@ final class HomeTimelineViewModel: NSObject { self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self) } .store(in: &disposeBag) - - // refresh after publish post - homeTimelineNavigationBarTitleViewModel.isPublished - .delay(for: 2, scheduler: DispatchQueue.main) - .sink { [weak self] isPublished in - guard let self = self else { return } - self.homeTimelineNeedRefresh.send() - } - .store(in: &disposeBag) - self.dataController.$records .removeDuplicates() .receive(on: DispatchQueue.main) @@ -114,8 +108,25 @@ final class HomeTimelineViewModel: NSObject { }) .store(in: &disposeBag) + networkErrorCount + .receive(on: DispatchQueue.main) + .map { errorCount in + return errorCount >= 5 + } + .assign(to: \.value, on: isOffline) + .store(in: &disposeBag) + self.dataController.loadInitial(kind: .home(timeline: timelineContext)) } + + func receiveLoadingStateCompletion(_ completion: Subscribers.Completion) { + switch completion { + case .failure: + networkErrorCount.value = networkErrorCount.value + 1 + case .finished: + networkErrorCount.value = 0 + } + } } extension HomeTimelineViewModel { diff --git a/Mastodon/Scene/HomeTimeline/TimelineStatusPill.swift b/Mastodon/Scene/HomeTimeline/TimelineStatusPill.swift new file mode 100644 index 000000000..2000d24d2 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/TimelineStatusPill.swift @@ -0,0 +1,94 @@ +// Copyright © 2024 Mastodon gGmbH. All rights reserved. + +import UIKit +import MastodonAsset +import MastodonLocalization + +class TimelineStatusPill: UIButton { + + var reason: Reason? + + func update(with reason: Reason) { + self.reason = reason + var configuration = UIButton.Configuration.filled() + + configuration.attributedTitle = AttributedString( + reason.title, attributes: AttributeContainer( + [ + .font: UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .bold)), + ] + )) + + let image = reason.image? + .withConfiguration(UIImage.SymbolConfiguration(paletteColors: [.white])) + .withConfiguration(UIImage.SymbolConfiguration(textStyle: .subheadline)) + .withConfiguration(UIImage.SymbolConfiguration(pointSize: 12, weight: .bold, scale: .medium)) + + configuration.image = image + configuration.imagePadding = 8 + configuration.cornerStyle = .capsule + configuration.background.backgroundColor = reason.backgroundColor + + self.configuration = configuration + + layer.shadowColor = reason.backgroundColor.cgColor + layer.shadowOpacity = 0.15 + layer.shadowOffset = .init(width: 0, height: 8) + layer.shadowRadius = 8 + } + + override func updateConfiguration() { + guard let reason, var updatedConfiguration = configuration else { + return super.updateConfiguration() + } + + switch state { + case .selected, .highlighted, .focused: + updatedConfiguration.baseForegroundColor = UIColor.white.withAlphaComponent(0.5) + default: + updatedConfiguration.baseForegroundColor = .white + } + + updatedConfiguration.background.backgroundColor = reason.backgroundColor + self.configuration = updatedConfiguration + } + + public enum Reason { + case newPosts + case postSent + case offline + + var image: UIImage? { + switch self { + case .newPosts: + return UIImage(systemName: "chevron.up") + case .postSent: + return UIImage(systemName: "checkmark") + case .offline: + return UIImage(systemName: "bolt.horizontal.fill") + } + } + + var backgroundColor: UIColor { + switch self { + case .newPosts: + return Asset.Colors.Brand.blurple.color + case .postSent: + return .systemGreen + case .offline: + return .systemGray + } + } + + var title: String { + switch self { + case .newPosts: + return L10n.Scene.HomeTimeline.TimelinePill.newPosts + case .postSent: + return L10n.Scene.HomeTimeline.TimelinePill.postSent + case .offline: + return L10n.Scene.HomeTimeline.TimelinePill.offline + } + } + } +} diff --git a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift deleted file mode 100644 index 28475c2d7..000000000 --- a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift +++ /dev/null @@ -1,221 +0,0 @@ -// -// HomeTimelineNavigationBarTitleView.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/15. -// - -import UIKit -import MastodonUI -import MastodonAsset -import MastodonLocalization - -protocol HomeTimelineNavigationBarTitleViewDelegate: AnyObject { - func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, logoButtonDidPressed sender: UIButton) - func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) -} - -final class HomeTimelineNavigationBarTitleView: UIView { - - let containerView = UIStackView() - - let logoButton = HighlightDimmableButton() - let button = RoundedEdgesButton() - let label = UILabel() - - // input - private var blockingState: HomeTimelineNavigationBarTitleViewModel.State? - weak var delegate: HomeTimelineNavigationBarTitleViewDelegate? - - // output - private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logo - - 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) - containerView.pinToParent() - - containerView.addArrangedSubview(logoButton) - button.translatesAutoresizingMaskIntoConstraints = false - containerView.addArrangedSubview(button) - NSLayoutConstraint.activate([ - button.heightAnchor.constraint(equalToConstant: 24).priority(.defaultHigh) - ]) - containerView.addArrangedSubview(label) - - configure(state: .logo) - logoButton.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.logoButtonDidPressed(_:)), for: .touchUpInside) - button.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.buttonDidPressed(_:)), for: .touchUpInside) - - logoButton.accessibilityIdentifier = "TitleButton" - logoButton.accessibilityTraits = [.header, .button] - button.accessibilityIdentifier = "TitleButton" - } -} - -extension HomeTimelineNavigationBarTitleView { - @objc private func logoButtonDidPressed(_ sender: UIButton) { - delegate?.homeTimelineNavigationBarTitleView(self, logoButtonDidPressed: sender) - } - - @objc private func buttonDidPressed(_ sender: UIButton) { - delegate?.homeTimelineNavigationBarTitleView(self, buttonDidPressed: sender) - } -} - -extension HomeTimelineNavigationBarTitleView { - - func resetContainer() { - logoButton.isHidden = true - button.isHidden = true - label.isHidden = true - } - - func configure(state: HomeTimelineNavigationBarTitleViewModel.State) { - self.state = state - - // check state block or not - guard blockingState == nil else { - return - } - - resetContainer() - - switch state { - case .logo: - logoButton.tintColor = Asset.Colors.Label.primary.color - logoButton.setImage(Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate), for: .normal) - logoButton.contentMode = .center - logoButton.isHidden = false - logoButton.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.Accessibility.logoLabel // TODO :i18n - logoButton.accessibilityHint = L10n.Scene.HomeTimeline.NavigationBarState.Accessibility.logoHint - case .newPostButton: - configureButton( - title: L10n.Scene.HomeTimeline.NavigationBarState.newPosts, - textColor: .white, - backgroundColor: Asset.Colors.Brand.blurple.color - ) - button.isHidden = false - button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.newPosts - case .offlineButton: - configureButton( - title: L10n.Scene.HomeTimeline.NavigationBarState.offline, - textColor: .white, - backgroundColor: Asset.Colors.danger.color - ) - button.isHidden = false - button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.offline - 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 - button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.publishing - case .publishedButton: - blockingState = state - configureButton( - title: L10n.Scene.HomeTimeline.NavigationBarState.published, - textColor: .white, - backgroundColor: Asset.Colors.successGreen.color - ) - button.isHidden = false - button.accessibilityLabel = L10n.Scene.HomeTimeline.NavigationBarState.published - - 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: .logo) - 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 deleted file mode 100644 index 9f736619b..000000000 --- a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift +++ /dev/null @@ -1,182 +0,0 @@ -// -// HomeTimelineNavigationBarTitleViewModel.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/15. -// - -import Combine -import Foundation -import UIKit -import MastodonCore - -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(.logo) - 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) - - Publishers.CombineLatest( - context.publisherService.$statusPublishers, - context.publisherService.statusPublishResult.prepend(.failure(AppError.badRequest)) - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] statusPublishers, publishResult in - guard let self = self else { return } - - if statusPublishers.isEmpty { - self.isPublishingPost.value = false - self.isPublished.value = false - } else { - self.isPublishingPost.value = true - switch publishResult { - case .success: - self.isPublished.value = true - case .failure: - self.isPublished.value = false - } - } - } - .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 .logo - } - .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 logo - 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/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index 6fddef9d2..fcd63dc9f 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -843,28 +843,20 @@ public enum L10n { public enum HomeTimeline { /// Home public static let title = L10n.tr("Localizable", "Scene.HomeTimeline.Title", fallback: "Home") - public enum NavigationBarState { - /// See new posts - public static let newPosts = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.NewPosts", fallback: "See new posts") - /// Offline - public static let offline = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Offline", fallback: "Offline") - /// Published! - public static let published = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Published", fallback: "Published!") - /// Publishing post... - public static let publishing = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Publishing", fallback: "Publishing post...") - public enum Accessibility { - /// Tap to scroll to top and tap again to previous location - public static let logoHint = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoHint", fallback: "Tap to scroll to top and tap again to previous location") - /// Mastodon - public static let logoLabel = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Accessibility.LogoLabel", fallback: "Mastodon") - } - } public enum TimelineMenu { /// Following public static let following = L10n.tr("Localizable", "Scene.HomeTimeline.TimelineMenu.Following", fallback: "Following") /// Local public static let localCommunity = L10n.tr("Localizable", "Scene.HomeTimeline.TimelineMenu.LocalCommunity", fallback: "Local") } + public enum TimelinePill { + /// New Posts + public static let newPosts = L10n.tr("Localizable", "Scene.HomeTimeline.TimelinePill.NewPosts", fallback: "New Posts") + /// Offline + public static let offline = L10n.tr("Localizable", "Scene.HomeTimeline.TimelinePill.Offline", fallback: "Offline") + /// Post Sent + public static let postSent = L10n.tr("Localizable", "Scene.HomeTimeline.TimelinePill.PostSent", fallback: "Post Sent") + } } public enum Login { /// Log you in on the server you created your account on. diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index 60fb82e8f..9c6b106d2 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -298,14 +298,11 @@ uploaded to Mastodon."; "Scene.Follower.Title" = "follower"; "Scene.Following.Footer" = "Follows from other servers are not displayed."; "Scene.Following.Title" = "following"; -"Scene.HomeTimeline.NavigationBarState.Accessibility.LogoHint" = "Tap to scroll to top and tap again to previous location"; -"Scene.HomeTimeline.NavigationBarState.Accessibility.LogoLabel" = "Mastodon"; -"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.TimelineMenu.Following" = "Following"; "Scene.HomeTimeline.TimelineMenu.LocalCommunity" = "Local"; +"Scene.HomeTimeline.TimelinePill.NewPosts" = "New Posts"; +"Scene.HomeTimeline.TimelinePill.Offline" = "Offline"; +"Scene.HomeTimeline.TimelinePill.PostSent" = "Post Sent"; "Scene.HomeTimeline.Title" = "Home"; "Scene.Login.ServerSearchField.Placeholder" = "Enter URL or search for your server"; "Scene.Login.Subtitle" = "Log you in on the server you created your account on.";