feat: implement post publishing progress bar UI and publish failure retry logic

This commit is contained in:
CMK 2021-03-29 17:44:52 +08:00
parent b9d9233e80
commit e3fa472f3f
23 changed files with 657 additions and 269 deletions

View File

@ -18,6 +18,10 @@
"discard_post_content": {
"title": "Discard Publish",
"message": "Confirm discard composed post content."
},
"publish_post_failure": {
"title": "Publish Failure",
"message": "Failed to publish the post.\nPlease check your internet connection."
}
},
"controls": {
@ -32,6 +36,7 @@
"continue": "Continue",
"cancel": "Cancel",
"discard": "Discard",
"try_again": "Try Again",
"take_photo": "Take photo",
"save_photo": "Save photo",
"sign_in": "Sign In",

View File

@ -73,8 +73,8 @@
2D7631B325C159F700929FB9 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631B225C159F700929FB9 /* Item.swift */; };
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */; };
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */; };
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */; };
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */; };
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */; };
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */; };
2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D84350425FF858100EECE90 /* UIScrollView.swift */; };
2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0125C7E4F2004F19B8 /* Mention.swift */; };
2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; };
@ -246,6 +246,7 @@
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; };
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; };
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; };
DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */; };
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; };
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; };
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; };
@ -370,8 +371,8 @@
2D7631B225C159F700929FB9 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<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 /* HomeTimelineNavigationBarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarState.swift; sourceTree = "<group>"; };
2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarView.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>"; };
2D927F0125C7E4F2004F19B8 /* Mention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = "<group>"; };
2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
@ -555,6 +556,7 @@
DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = "<group>"; };
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = "<group>"; };
DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPublishService.swift; sourceTree = "<group>"; };
DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = "<group>"; };
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = "<group>"; };
@ -707,6 +709,7 @@
2D38F1D325CD463600561493 /* HomeTimeline */ = {
isa = PBXGroup;
children = (
DB1F239626117C360057430E /* View */,
2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */,
2D38F1DE25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift */,
2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */,
@ -715,8 +718,6 @@
2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */,
2D38F1F025CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift */,
2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */,
2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */,
2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */,
);
path = HomeTimeline;
sourceTree = "<group>";
@ -785,6 +786,7 @@
2DA6054625F716A2006356F9 /* PlaybackState.swift */,
5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */,
DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */,
DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */,
);
path = Service;
sourceTree = "<group>";
@ -970,6 +972,15 @@
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 = (
@ -1832,12 +1843,12 @@
2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */,
2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */,
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */,
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */,
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */,
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */,
DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */,
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */,
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */,
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */,
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */,
DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */,
@ -1925,6 +1936,7 @@
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */,
DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */,
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */,
0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */,
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */,

View File

@ -44,6 +44,7 @@ internal enum Asset {
internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background")
internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background")
internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background")
internal static let success = ColorAsset(name: "Colors/Background/success")
internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background")
internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background")
internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background")

View File

@ -25,6 +25,12 @@ internal enum L10n {
/// Discard Publish
internal static let title = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Title")
}
internal enum PublishPostFailure {
/// Failed to publish the post.\nPlease check your internet connection.
internal static let message = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Message")
/// Publish Failure
internal static let title = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Title")
}
internal enum ServerError {
/// Server Error
internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title")
@ -76,6 +82,8 @@ internal enum L10n {
internal static let signUp = L10n.tr("Localizable", "Common.Controls.Actions.SignUp")
/// Take photo
internal static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto")
/// Try Again
internal static let tryAgain = L10n.tr("Localizable", "Common.Controls.Actions.TryAgain")
}
internal enum Status {
/// Tap to reveal that may be sensitive

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.604",
"green" : "0.741",
"red" : "0.475"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -2,6 +2,9 @@
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content.";
"Common.Alerts.DiscardPostContent.Title" = "Discard Publish";
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
Please check your internet connection.";
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
"Common.Alerts.ServerError.Title" = "Server Error";
"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure";
"Common.Alerts.VoteFailure.PollExpired" = "The poll has expired";
@ -23,6 +26,7 @@
"Common.Controls.Actions.SignIn" = "Sign In";
"Common.Controls.Actions.SignUp" = "Sign Up";
"Common.Controls.Actions.TakePhoto" = "Take photo";
"Common.Controls.Actions.TryAgain" = "Try Again";
"Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive";
"Common.Controls.Status.Poll.Closed" = "Closed";
"Common.Controls.Status.Poll.TimeLeft" = "%@ left";

View File

@ -483,7 +483,7 @@ extension ComposeViewController {
// TODO: handle error
return
}
context.statusPublishService.publish(composeViewModel: viewModel)
dismiss(animated: true, completion: nil)
}

View File

@ -21,6 +21,7 @@ extension ComposeViewModel {
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
viewModel?.publishStateMachinePublisher.value = self
}
}
}
@ -48,6 +49,8 @@ extension ComposeViewModel.PublishState {
return
}
viewModel.updatePublishDate()
let domain = mastodonAuthenticationBox.domain
let attachmentServices = viewModel.attachmentServices.value
let mediaIDs = attachmentServices.compactMap { attachmentService in
@ -131,7 +134,13 @@ extension ComposeViewModel.PublishState {
class Fail: ComposeViewModel.PublishState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// allow discard publishing
return stateClass == Publishing.self || stateClass == Finish.self
return stateClass == Publishing.self || stateClass == Discard.self
}
}
class Discard: ComposeViewModel.PublishState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return false
}
}

View File

@ -39,12 +39,15 @@ final class ComposeViewModel {
PublishState.Initial(viewModel: self),
PublishState.Publishing(viewModel: self),
PublishState.Fail(viewModel: self),
PublishState.Discard(viewModel: self),
PublishState.Finish(viewModel: self),
])
stateMachine.enter(PublishState.Initial.self)
return stateMachine
}()
private(set) lazy var publishStateMachinePublisher = CurrentValueSubject<PublishState?, Never>(nil)
private(set) var publishDate = Date() // update it when enter Publishing state
// UI & UX
let title: CurrentValueSubject<String, Never>
let shouldDismiss = CurrentValueSubject<Bool, Never>(true)
@ -316,6 +319,10 @@ extension ComposeViewModel {
let attribute = ComposeStatusItem.ComposePollOptionAttribute()
pollOptionAttributes.value = pollOptionAttributes.value + [attribute]
}
func updatePublishDate() {
publishDate = Date()
}
}
// MARK: - MastodonAttachmentServiceDelegate

View File

@ -1,156 +0,0 @@
//
// HomeTimelineNavigationBarState.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/15.
//
import Combine
import Foundation
import UIKit
final class HomeTimelineNavigationBarState {
static let errorCountMax: Int = 3
var disposeBag = Set<AnyCancellable>()
var errorCountDownDispose: AnyCancellable?
var timerDispose: AnyCancellable?
var networkErrorCountSubject = PassthroughSubject<Bool, Never>()
var newTopContent = CurrentValueSubject<Bool, Never>(false)
var hasContentBeforeFetching: Bool = true
weak var viewController: HomeTimelineViewController?
let timestampUpdatePublisher = Timer.publish(every: NavigationBarProgressView.progressAnimationDuration, on: .main, in: .common)
.autoconnect()
.share()
.eraseToAnyPublisher()
init() {
reCountdown()
subscribeNewContent()
addGesture()
}
}
extension HomeTimelineNavigationBarState {
func showOfflineInNavigationBar() {
HomeTimelineNavigationBarView.progressView.removeFromSuperview()
viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.offlineView
}
func showNewPostsInNavigationBar() {
HomeTimelineNavigationBarView.progressView.removeFromSuperview()
viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.newPostsView
}
func showPublishingNewPostInNavigationBar() {
let progressView = HomeTimelineNavigationBarView.progressView
if let navigationBar = viewController?.navigationBar(), progressView.superview == nil {
navigationBar.addSubview(progressView)
NSLayoutConstraint.activate([
progressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor),
progressView.leadingAnchor.constraint(equalTo: navigationBar.leadingAnchor),
progressView.trailingAnchor.constraint(equalTo: navigationBar.trailingAnchor),
progressView.heightAnchor.constraint(equalToConstant: 3)
])
}
progressView.layoutIfNeeded()
progressView.progress = 0
viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.publishingLabel
var times: Int = 0
timerDispose = timestampUpdatePublisher
.map { _ in
times += 1
return Double(times)
}
.scan(0) { value, count in
value + 1 / pow(Double(2), count)
}
.receive(on: DispatchQueue.main)
.sink { value in
print(value)
progressView.progress = CGFloat(value)
}
}
func showPublishedInNavigationBar() {
timerDispose = nil
HomeTimelineNavigationBarView.progressView.removeFromSuperview()
viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.publishedView
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) {
self.showMastodonLogoInNavigationBar()
}
}
func showMastodonLogoInNavigationBar() {
HomeTimelineNavigationBarView.progressView.removeFromSuperview()
viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.mastodonLogoTitleView
}
}
extension HomeTimelineNavigationBarState {
func handleScrollViewDidScroll(_ scrollView: UIScrollView) {
let contentOffsetY = scrollView.contentOffset.y
let isShowingNewPostsNew = viewController?.navigationItem.titleView === HomeTimelineNavigationBarView.newPostsView
if !isShowingNewPostsNew {
return
}
let isTop = contentOffsetY < -scrollView.contentInset.top
if isTop {
newTopContent.value = false
showMastodonLogoInNavigationBar()
}
}
func addGesture() {
let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer
tapGesture.addTarget(self, action: #selector(HomeTimelineNavigationBarState.newPostsNewDidPressed(_:)))
HomeTimelineNavigationBarView.newPostsView.addGestureRecognizer(tapGesture)
}
@objc func newPostsNewDidPressed(_ sender: UITapGestureRecognizer) {
if newTopContent.value == true {
viewController?.tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: true)
}
}
}
extension HomeTimelineNavigationBarState {
func subscribeNewContent() {
newTopContent
.receive(on: DispatchQueue.main)
.sink { [weak self] newContent in
guard let self = self else { return }
if self.hasContentBeforeFetching, newContent {
self.showNewPostsInNavigationBar()
}
}
.store(in: &disposeBag)
}
func reCountdown() {
errorCountDownDispose = networkErrorCountSubject
.scan(0) { value, _ in value + 1 }
.sink(receiveValue: { [weak self] errorCount in
guard let self = self else { return }
if errorCount >= HomeTimelineNavigationBarState.errorCountMax {
self.showOfflineInNavigationBar()
}
})
}
func receiveCompletion(completion: Subscribers.Completion<Error>) {
switch completion {
case .failure:
networkErrorCountSubject.send(false)
case .finished:
reCountdown()
let isShowingOfflineView = viewController?.navigationItem.titleView === HomeTimelineNavigationBarView.offlineView
if isShowingOfflineView {
showMastodonLogoInNavigationBar()
}
}
}
}

View File

@ -1,80 +0,0 @@
//
// HomeTimelineNavigationBarView.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/15.
//
import UIKit
final class HomeTimelineNavigationBarView {
static let mastodonLogoTitleView: UIImageView = {
let imageView = UIImageView(image: Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate))
imageView.tintColor = Asset.Colors.Label.primary.color
return imageView
}()
static let offlineView: UIView = {
let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.danger.color)
let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.offline)
HomeTimelineNavigationBarView.addLabelToView(label: label, view: view)
return view
}()
static let newPostsView: UIView = {
let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.Button.normal.color)
let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.newPosts)
HomeTimelineNavigationBarView.addLabelToView(label: label, view: view)
return view
}()
static var publishedView: UIView = {
let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.lightSuccessGreen.color)
let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.published)
HomeTimelineNavigationBarView.addLabelToView(label: label, view: view)
return view
}()
static var progressView: NavigationBarProgressView = {
let view = NavigationBarProgressView()
return view
}()
static var publishingLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .black
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold))
label.text = L10n.Scene.HomeTimeline.NavigationBarState.publishing
return label
}()
static func addLabelToView(label: UILabel, view: UIView) {
view.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
view.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: 16),
label.topAnchor.constraint(equalTo: view.topAnchor, constant: 1),
view.bottomAnchor.constraint(equalTo: label.bottomAnchor, constant: 1),
view.heightAnchor.constraint(equalToConstant: 24),
])
}
static func backgroundViewWithColor(color: UIColor) -> UIView {
let view = UIView()
view.backgroundColor = color
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = 12
view.clipsToBounds = true
return view
}
static func contentLabel(text: String) -> UILabel {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .bold))
label.text = text
return label
}
}

View File

@ -23,6 +23,8 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency {
var disposeBag = Set<AnyCancellable>()
private(set) lazy var viewModel = HomeTimelineViewModel(context: context)
let titleView = HomeTimelineNavigationBarTitleView()
let settingBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem()
barButtonItem.tintColor = Asset.Colors.Label.highlight.color
@ -49,6 +51,12 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency {
return tableView
}()
let publishProgressView: UIProgressView = {
let progressView = UIProgressView(progressViewStyle: .bar)
progressView.alpha = 0
return progressView
}()
let refreshControl = UIRefreshControl()
deinit {
@ -64,8 +72,19 @@ extension HomeTimelineViewController {
title = L10n.Scene.HomeTimeline.title
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
navigationItem.titleView = HomeTimelineNavigationBarView.mastodonLogoTitleView
navigationItem.leftBarButtonItem = settingBarButtonItem
navigationItem.titleView = titleView
titleView.delegate = self
viewModel.homeTimelineNavigationBarTitleViewModel.state
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
self.titleView.configure(state: state)
}
.store(in: &disposeBag)
#if DEBUG
// long press to trigger debug menu
settingBarButtonItem.menu = debugMenu
@ -95,9 +114,16 @@ extension HomeTimelineViewController {
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
publishProgressView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(publishProgressView)
NSLayoutConstraint.activate([
publishProgressView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
publishProgressView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
publishProgressView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
viewModel.tableView = tableView
viewModel.viewController = self
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
tableView.delegate = self
tableView.prefetchDataSource = self
@ -121,9 +147,35 @@ extension HomeTimelineViewController {
}
}
.store(in: &disposeBag)
viewModel.homeTimelineNavigationBarTitleViewModel.publishingProgress
.receive(on: DispatchQueue.main)
.sink { [weak self] progress in
guard let self = self else { return }
guard progress > 0 else {
let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut)
dismissAnimator.addAnimations {
self.publishProgressView.alpha = 0
}
dismissAnimator.addCompletion { _ in
self.publishProgressView.setProgress(0, animated: false)
}
dismissAnimator.startAnimation()
return
}
if self.publishProgressView.alpha == 0 {
let progressAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeOut)
progressAnimator.addAnimations {
self.publishProgressView.alpha = 1
}
progressAnimator.startAnimation()
}
self.publishProgressView.setProgress(progress, animated: true)
}
.store(in: &disposeBag)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
@ -207,7 +259,7 @@ extension HomeTimelineViewController {
extension HomeTimelineViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
handleScrollViewDidScroll(scrollView)
self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView)
self.viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView)
}
}
@ -221,8 +273,9 @@ extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer {
// MARK: - UITableViewDelegate
extension HomeTimelineViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return 200
// TODO:
// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
// guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
//
@ -232,7 +285,7 @@ extension HomeTimelineViewController: UITableViewDelegate {
// // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription)
//
// return ceil(frame.height)
// }
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
handleTableView(tableView, willDisplay: cell, forRowAt: indexPath)
@ -364,3 +417,23 @@ extension HomeTimelineViewController: StatusTableViewCellDelegate {
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
func parent() -> UIViewController { return self }
}
// MARK: - HomeTimelineNavigationBarTitleViewDelegate
extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate {
func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) {
switch titleView.state {
case .newPostButton:
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let indexPath = IndexPath(row: 0, section: 0)
guard diffableDataSource.itemIdentifier(for: indexPath) != nil else { return }
tableView.scrollToRow(at: indexPath, at: .top, animated: true)
case .offlineButton:
// TODO: retry
break
case .publishedButton:
break
default:
break
}
}
}

View File

@ -73,7 +73,7 @@ extension HomeTimelineViewModel.LoadLatestState {
stateMachine.enter(Fail.self)
return
}
viewModel.homeTimelineNavigationBarState.hasContentBeforeFetching = !latestTootIDs.isEmpty
let end = CACurrentMediaTime()
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect toots id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start)
@ -81,7 +81,7 @@ extension HomeTimelineViewModel.LoadLatestState {
viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox)
.receive(on: DispatchQueue.main)
.sink { completion in
viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion)
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion)
switch completion {
case .failure(let error):
// TODO: handle error
@ -102,9 +102,10 @@ extension HomeTimelineViewModel.LoadLatestState {
if newToots.isEmpty {
viewModel.isFetchingLatestTimeline.value = false
viewModel.homeTimelineNavigationBarState.newTopContent.value = false
} else {
viewModel.homeTimelineNavigationBarState.newTopContent.value = true
if !latestTootIDs.isEmpty {
viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming()
}
}
}
.store(in: &viewModel.disposeBag)

View File

@ -68,7 +68,7 @@ extension HomeTimelineViewModel.LoadMiddleState {
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
.receive(on: DispatchQueue.main)
.sink { completion in
viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion)
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion)
switch completion {
case .failure(let error):
// TODO: handle error

View File

@ -58,7 +58,7 @@ extension HomeTimelineViewModel.LoadOldestState {
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
.receive(on: DispatchQueue.main)
.sink { completion in
viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion)
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion)
switch completion {
case .failure(let error):
os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)

View File

@ -28,17 +28,11 @@ final class HomeTimelineViewModel: NSObject {
let fetchedResultsController: NSFetchedResultsController<HomeTimelineIndex>
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
let viewDidAppear = PassthroughSubject<Void, Never>()
let homeTimelineNavigationBarState = HomeTimelineNavigationBarState()
let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel
weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
weak var tableView: UITableView?
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
weak var viewController: HomeTimelineViewController? {
willSet(value) {
self.homeTimelineNavigationBarState.viewController = value
}
}
// output
// top loader
@ -90,6 +84,7 @@ final class HomeTimelineViewModel: NSObject {
return controller
}()
self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context)
super.init()
fetchedResultsController.delegate = self

View File

@ -0,0 +1,210 @@
//
// HomeTimelineNavigationBarTitleView.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/15.
//
import os.log
import UIKit
protocol HomeTimelineNavigationBarTitleViewDelegate: class {
func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton)
}
final class HomeTimelineNavigationBarTitleView: UIView {
let containerView = UIStackView()
let imageView = UIImageView()
let button = RoundedEdgesButton()
let label = UILabel()
// input
private var blockingState: HomeTimelineNavigationBarTitleViewModel.State?
weak var delegate: HomeTimelineNavigationBarTitleViewDelegate?
// output
private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logoImage
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension HomeTimelineNavigationBarTitleView {
private func _init() {
containerView.translatesAutoresizingMaskIntoConstraints = false
addSubview(containerView)
NSLayoutConstraint.activate([
containerView.topAnchor.constraint(equalTo: topAnchor),
containerView.leadingAnchor.constraint(equalTo: leadingAnchor),
containerView.trailingAnchor.constraint(equalTo: trailingAnchor),
containerView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
containerView.addArrangedSubview(imageView)
button.translatesAutoresizingMaskIntoConstraints = false
containerView.addArrangedSubview(button)
NSLayoutConstraint.activate([
button.heightAnchor.constraint(equalToConstant: 24).priority(.defaultHigh)
])
containerView.addArrangedSubview(label)
configure(state: .logoImage)
button.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.buttonDidPressed(_:)), for: .touchUpInside)
}
}
extension HomeTimelineNavigationBarTitleView {
@objc private func buttonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.homeTimelineNavigationBarTitleView(self, buttonDidPressed: sender)
}
}
extension HomeTimelineNavigationBarTitleView {
func resetContainer() {
imageView.isHidden = true
button.isHidden = true
label.isHidden = true
}
func configure(state: HomeTimelineNavigationBarTitleViewModel.State) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: configure title view: %s", ((#file as NSString).lastPathComponent), #line, #function, state.rawValue)
self.state = state
// check state block or not
guard blockingState == nil else {
return
}
resetContainer()
switch state {
case .logoImage:
imageView.tintColor = Asset.Colors.Label.primary.color
imageView.image = Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate)
imageView.contentMode = .center
imageView.isHidden = false
case .newPostButton:
configureButton(
title: L10n.Scene.HomeTimeline.NavigationBarState.newPosts,
textColor: .white,
backgroundColor: Asset.Colors.Button.normal.color
)
button.isHidden = false
case .offlineButton:
configureButton(
title: L10n.Scene.HomeTimeline.NavigationBarState.offline,
textColor: .white,
backgroundColor: Asset.Colors.Background.danger.color
)
button.isHidden = false
case .publishingPostLabel:
label.font = .systemFont(ofSize: 17, weight: .semibold)
label.textColor = Asset.Colors.Label.primary.color
label.text = L10n.Scene.HomeTimeline.NavigationBarState.publishing
label.textAlignment = .center
label.isHidden = false
case .publishedButton:
blockingState = state
configureButton(
title: L10n.Scene.HomeTimeline.NavigationBarState.published,
textColor: .white,
backgroundColor: Asset.Colors.Background.success.color
)
button.isHidden = false
let presentDuration: TimeInterval = 0.33
let scaleAnimator = UIViewPropertyAnimator(duration: presentDuration, timingParameters: UISpringTimingParameters())
button.transform = CGAffineTransform(scaleX: 0.5, y: 0.5)
scaleAnimator.addAnimations {
self.button.transform = .identity
}
let alphaAnimator = UIViewPropertyAnimator(duration: presentDuration, curve: .easeInOut)
button.alpha = 0.3
alphaAnimator.addAnimations {
self.button.alpha = 1
}
scaleAnimator.startAnimation()
alphaAnimator.startAnimation()
let dismissDuration: TimeInterval = 3
let dissolveAnimator = UIViewPropertyAnimator(duration: dismissDuration, curve: .easeInOut)
dissolveAnimator.addAnimations({
self.button.alpha = 0
}, delayFactor: 0.9) // at 2.7s
dissolveAnimator.addCompletion { _ in
self.blockingState = nil
self.configure(state: self.state)
self.button.alpha = 1
}
dissolveAnimator.startAnimation()
}
}
private func configureButton(title: String, textColor: UIColor, backgroundColor: UIColor) {
button.setBackgroundImage(.placeholder(color: backgroundColor), for: .normal)
button.setBackgroundImage(.placeholder(color: backgroundColor.withAlphaComponent(0.5)), for: .highlighted)
button.setTitleColor(textColor, for: .normal)
button.setTitleColor(textColor.withAlphaComponent(0.5), for: .highlighted)
button.setTitle(title, for: .normal)
button.contentEdgeInsets = UIEdgeInsets(top: 1, left: 16, bottom: 1, right: 16)
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct HomeTimelineNavigationBarTitleView_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview(width: 375) {
let titleView = HomeTimelineNavigationBarTitleView()
titleView.configure(state: .logoImage)
return titleView
}
.previewLayout(.fixed(width: 375, height: 44))
UIViewPreview(width: 150) {
let titleView = HomeTimelineNavigationBarTitleView()
titleView.configure(state: .newPostButton)
return titleView
}
.previewLayout(.fixed(width: 150, height: 24))
UIViewPreview(width: 120) {
let titleView = HomeTimelineNavigationBarTitleView()
titleView.configure(state: .offlineButton)
return titleView
}
.previewLayout(.fixed(width: 120, height: 24))
UIViewPreview(width: 375) {
let titleView = HomeTimelineNavigationBarTitleView()
titleView.configure(state: .publishingPostLabel)
return titleView
}
.previewLayout(.fixed(width: 375, height: 44))
UIViewPreview(width: 120) {
let titleView = HomeTimelineNavigationBarTitleView()
titleView.configure(state: .publishedButton)
return titleView
}
.previewLayout(.fixed(width: 120, height: 24))
}
}
}
#endif

View File

@ -0,0 +1,174 @@
//
// HomeTimelineNavigationBarTitleViewModel.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/15.
//
import Combine
import Foundation
import UIKit
final class HomeTimelineNavigationBarTitleViewModel {
static let offlineCounterLimit = 3
var disposeBag = Set<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>(.logoImage)
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)
context.statusPublishService.latestPublishingComposeViewModel
.receive(on: DispatchQueue.main)
.sink { [weak self] composeViewModel in
guard let self = self else { return }
guard let composeViewModel = composeViewModel,
let state = composeViewModel.publishStateMachine.currentState else {
self.isPublishingPost.value = false
self.isPublished.value = false
return
}
self.isPublishingPost.value = state is ComposeViewModel.PublishState.Publishing || state is ComposeViewModel.PublishState.Fail
self.isPublished.value = state is ComposeViewModel.PublishState.Finish
}
.store(in: &disposeBag)
Publishers.CombineLatest4(
hasNewPosts.eraseToAnyPublisher(),
isOffline.eraseToAnyPublisher(),
isPublishingPost.eraseToAnyPublisher(),
isPublished.eraseToAnyPublisher()
)
.map { hasNewPosts, isOffline, isPublishingPost, isPublished -> State in
guard !isPublished else { return .publishedButton }
guard !isPublishingPost else { return .publishingPostLabel }
guard !isOffline else { return .offlineButton }
guard !hasNewPosts else { return .newPostButton }
return .logoImage
}
.receive(on: DispatchQueue.main)
.assign(to: \.value, on: state)
.store(in: &disposeBag)
state
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
switch state {
case .publishingPostLabel:
self.setupPublishingProgress()
default:
self.suspendPublishingProgress()
}
}
.store(in: &disposeBag)
}
}
extension HomeTimelineNavigationBarTitleViewModel {
// state order by priority from low to high
enum State: String {
case logoImage
case newPostButton
case offlineButton
case publishingPostLabel
case publishedButton
}
}
// MARK: - New post state
extension HomeTimelineNavigationBarTitleViewModel {
func newPostsIncoming() {
hasNewPosts.value = true
}
private func resetNewPostState() {
hasNewPosts.value = false
}
}
// MARK: - Offline state
extension HomeTimelineNavigationBarTitleViewModel {
func resetOfflineCounterListener() {
networkErrorCount.value = 0
}
func receiveLoadingStateCompletion(_ completion: Subscribers.Completion<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

@ -123,6 +123,32 @@ extension MainTabBarController {
}
}
.store(in: &disposeBag)
// handle post failure
context.statusPublishService
.latestPublishingComposeViewModel
.receive(on: DispatchQueue.main)
.sink { [weak self] composeViewModel in
guard let self = self else { return }
guard let composeViewModel = composeViewModel else { return }
guard let currentState = composeViewModel.publishStateMachine.currentState else { return }
guard currentState is ComposeViewModel.PublishState.Fail else { return }
let alertController = UIAlertController(title: L10n.Common.Alerts.PublishPostFailure.title, message: L10n.Common.Alerts.PublishPostFailure.message, preferredStyle: .alert)
let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self, weak composeViewModel] _ in
guard let self = self else { return }
guard let composeViewModel = composeViewModel else { return }
self.context.statusPublishService.remove(composeViewModel: composeViewModel)
}
alertController.addAction(discardAction)
let retryAction = UIAlertAction(title: L10n.Common.Controls.Actions.tryAgain, style: .default) { [weak composeViewModel] _ in
guard let composeViewModel = composeViewModel else { return }
composeViewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self)
}
alertController.addAction(retryAction)
self.present(alertController, animated: true, completion: nil)
}
.store(in: &disposeBag)
#if DEBUG
// selectedIndex = 1

View File

@ -16,8 +16,8 @@ final class StatusPrefetchingService {
typealias TaskID = String
let workingQueue = DispatchQueue(label: "status-prefetching-service-working-queue")
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.StatusPrefetchingService.working-queue")
var disposeBag = Set<AnyCancellable>()
private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:]

View File

@ -0,0 +1,78 @@
//
// StatusPublishService.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-26.
//
import os.log
import Foundation
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
final class StatusPublishService {
var disposeBag = Set<AnyCancellable>()
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.StatusPublishService.working-queue")
// input
var viewModels = CurrentValueSubject<[ComposeViewModel], Never>([]) // use strong reference to retain the view models
// output
let composeViewModelDidUpdatePublisher = PassthroughSubject<Void, Never>()
let latestPublishingComposeViewModel = CurrentValueSubject<ComposeViewModel?, Never>(nil)
init() {
Publishers.CombineLatest(
viewModels.eraseToAnyPublisher(),
composeViewModelDidUpdatePublisher.eraseToAnyPublisher()
)
.map { viewModels, _ in viewModels.last }
.assign(to: \.value, on: latestPublishingComposeViewModel)
.store(in: &disposeBag)
}
}
extension StatusPublishService {
func publish(composeViewModel: ComposeViewModel) {
workingQueue.sync {
guard !self.viewModels.value.contains(where: { $0 === composeViewModel }) else { return }
self.viewModels.value = self.viewModels.value + [composeViewModel]
composeViewModel.publishStateMachinePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self, weak composeViewModel] state in
guard let self = self else { return }
guard let composeViewModel = composeViewModel else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: composeViewModelDidUpdate", ((#file as NSString).lastPathComponent), #line, #function)
self.composeViewModelDidUpdatePublisher.send()
switch state {
case is ComposeViewModel.PublishState.Finish:
self.remove(composeViewModel: composeViewModel)
default:
break
}
}
.store(in: &composeViewModel.disposeBag) // cancel subscription when viewModel dealloc
}
}
func remove(composeViewModel: ComposeViewModel) {
workingQueue.async {
var viewModels = self.viewModels.value
viewModels.removeAll(where: { $0 === composeViewModel })
self.viewModels.value = viewModels
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: composeViewModel removed", ((#file as NSString).lastPathComponent), #line, #function)
}
}
}

View File

@ -14,7 +14,7 @@ import os.log
final class VideoPlaybackService {
var disposeBag = Set<AnyCancellable>()
let workingQueue = DispatchQueue(label: "com.twidere.twiderex.video-playback-service.working-queue")
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.VideoPlaybackService.working-queue")
private(set) var viewPlayerViewModelDict: [URL: VideoPlayerViewModel] = [:]
// only for video kind

View File

@ -27,6 +27,7 @@ class AppContext: ObservableObject {
let audioPlaybackService = AudioPlaybackService()
let videoPlaybackService = VideoPlaybackService()
let statusPrefetchingService: StatusPrefetchingService
let statusPublishService = StatusPublishService()
let documentStore: DocumentStore
private var documentStoreSubscription: AnyCancellable!