New 💊 (IOS-234) (#1268)

- Replaces the view previously in the Navigation Bar with the new,
so-called Timeline Status Pill
- We use it to show three states: When a post has been published
(successfully), when new statuses are available or when five (I slightly
increased the limit) requests failed
- The Timeline Status Pill features some fancy animations and a little
shadow
- For further details have a look at IOS-234 🙂 


![ios_234_iphone](https://github.com/mastodon/mastodon-ios/assets/2580019/c77df339-aa72-406f-8be3-572acb244246)

![ios_234_ipad](https://github.com/mastodon/mastodon-ios/assets/2580019/f2fafc4c-ed5a-4afb-86a7-5de69325997b)

To simulate the different reasons I used this snippet. When you press
the Settings-button, you should see the 💊 :

```diff
diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift
index 8559025d4..0eb27d0e8 100644
--- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift
+++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift
@@ -488,9 +488,14 @@ extension HomeTimelineViewController {
     }
     
     @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) {
-        guard let setting = context.settingService.currentSetting.value else { return }
+        // don't commit
+        timelinePill.update(with: .newPosts)
+        showTimelinePill()
 
-        _ = coordinator.present(scene: .settings(setting: setting), from: self, transition: .none)
+//        guard let setting = context.settingService.currentSetting.value else { return }
+//
+//        _ = coordinator.present(scene: .settings(setting: setting), from: self, transition: .none)
+        // don't commit
     }
 
     @objc private func refreshControlValueChanged(_ sender: RefreshControl) {

```
This commit is contained in:
Nathan Mattes 2024-04-10 15:06:22 +02:00 committed by GitHub
commit 1a2cf272a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 274 additions and 551 deletions

View File

@ -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": {

View File

@ -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": {

View File

@ -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 = "<group>"; };
2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewControllerAppearance.swift; sourceTree = "<group>"; };
2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModelNavigationDelegateShim.swift; sourceTree = "<group>"; };
2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleViewModel.swift; sourceTree = "<group>"; };
2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleView.swift; sourceTree = "<group>"; };
2D84350425FF858100EECE90 /* UIScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScrollView.swift; sourceTree = "<group>"; };
2D939AB425EDD8A90076FA61 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = "<group>"; };
2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewController.swift; sourceTree = "<group>"; };
@ -779,6 +776,7 @@
D8318A872A4468D300C0FB73 /* NotificationSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsViewController.swift; sourceTree = "<group>"; };
D8318A892A4468DC00C0FB73 /* AboutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = "<group>"; };
D8363B1529469CE200A74079 /* OnboardingNextView.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = OnboardingNextView.swift; sourceTree = "<group>"; tabWidth = 4; };
D84738D32BBD9ABE00ECD52B /* TimelineStatusPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusPill.swift; sourceTree = "<group>"; };
D84C099D2B0F9E33009E685E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
D84C099F2B0F9E41009E685E /* Setup.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Setup.md; sourceTree = "<group>"; };
D84C09A02B0F9E41009E685E /* How-it-works.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "How-it-works.md"; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -1983,15 +1981,6 @@
path = TableView;
sourceTree = "<group>";
};
DB1F239626117C360057430E /* View */ = {
isa = PBXGroup;
children = (
2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */,
2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */,
);
path = View;
sourceTree = "<group>";
};
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 */,

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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<Bool, Never>(false)
/// Becomes `true` if `networkErrorCount` is bigger than 5
let isOffline = CurrentValueSubject<Bool, Never>(false)
var networkErrorCount = CurrentValueSubject<Int, Never>(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<Error>) {
switch completion {
case .failure:
networkErrorCount.value = networkErrorCount.value + 1
case .finished:
networkErrorCount.value = 0
}
}
}
extension HomeTimelineViewModel {

View File

@ -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
}
}
}
}

View File

@ -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

View File

@ -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<AnyCancellable>()
private(set) var publishingProgressSubscription: AnyCancellable?
// input
let context: AppContext
var networkErrorCount = CurrentValueSubject<Int, Never>(0)
var networkErrorPublisher = PassthroughSubject<Void, Never>()
// output
let state = CurrentValueSubject<State, Never>(.logo)
let hasNewPosts = CurrentValueSubject<Bool, Never>(false)
let isOffline = CurrentValueSubject<Bool, Never>(false)
let isPublishingPost = CurrentValueSubject<Bool, Never>(false)
let isPublished = CurrentValueSubject<Bool, Never>(false)
let publishingProgress = PassthroughSubject<Float, Never>()
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<Error>) {
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)
// }
//
//}

View File

@ -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.

View File

@ -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.";