Refactor: Immutable view model for ProfileViewController

Goal: Begin to centralize the locus of view updates for easier bug fixing and future design flexibility.

Remaining: The supplementary views are still heavily using Combine.

Fixes #1372 [BUG] Cannot save new profile fields
Fixes iOS-340
This commit is contained in:
shannon 2024-12-05 10:32:49 -05:00
parent c927ca278b
commit af6272014a
15 changed files with 1016 additions and 1104 deletions

View File

@ -103,15 +103,9 @@ final public class SceneCoordinator {
let relationship = try await APIService.shared.relationship(forAccounts: [account], authenticationBox: authenticationBox).value.first
let profileViewModel = ProfileViewModel(
context: appContext,
authenticationBox: authenticationBox,
account: account,
relationship: relationship,
me: me
)
let profileType: ProfileViewController.ProfileType = me == account ? .me(me) : .notMe(me: me, displayAccount: account, relationship: relationship)
_ = self.present(
scene: .profile(viewModel: profileViewModel),
scene: .profile(profileType),
from: from,
transition: .show
)
@ -190,7 +184,7 @@ extension SceneCoordinator {
// profile
case accountList(viewModel: AccountListViewModel)
case profile(viewModel: ProfileViewModel)
case profile(ProfileViewController.ProfileType)
case favorite(viewModel: FavoriteViewModel)
case follower(viewModel: FollowerListViewModel)
case following(viewModel: FollowingListViewModel)
@ -449,9 +443,8 @@ private extension SceneCoordinator {
let accountListViewController = AccountListViewController()
accountListViewController.viewModel = viewModel
viewController = accountListViewController
case .profile(let viewModel):
let _viewController = ProfileViewController()
_viewController.viewModel = viewModel
case .profile(let profileType):
let _viewController = ProfileViewController(profileType, authenticationBox: AuthenticationServiceProvider.shared.currentActiveUser.value!)
viewController = _viewController
case .bookmark(let viewModel):
let _viewController = BookmarkViewController()

View File

@ -26,7 +26,6 @@ extension ProfileFieldSection {
static func diffableDataSource(
collectionView: UICollectionView,
context: AppContext,
configuration: Configuration
) -> UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem> {
collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.headerReuseIdentifer)

View File

@ -120,16 +120,9 @@ extension DataSourceFacade {
coordinator.hideLoading()
let profileViewModel = ProfileViewModel(
context: AppContext.shared,
authenticationBox: provider.authenticationBox,
account: account,
relationship: relationship,
me: me
)
let profileType: ProfileViewController.ProfileType = me == account ? .me(me) : .notMe(me: me, displayAccount: account, relationship: relationship)
_ = coordinator.present(
scene: .profile(viewModel: profileViewModel),
scene: .profile(profileType),
from: provider,
transition: .show
)

View File

@ -33,7 +33,9 @@ final class ProfileAboutViewController: UIViewController {
return collectionView
}()
public var currentEditableFields: [ (String, String) ] {
return viewModel.profileInfoEditing.editedFields
}
}
extension ProfileAboutViewController {

View File

@ -18,7 +18,6 @@ extension ProfileAboutViewModel {
) {
let diffableDataSource = ProfileFieldSection.diffableDataSource(
collectionView: collectionView,
context: context,
configuration: ProfileFieldSection.Configuration(
profileFieldCollectionViewCellDelegate: profileFieldCollectionViewCellDelegate,
profileFieldEditCollectionViewCellDelegate: profileFieldEditCollectionViewCellDelegate

View File

@ -18,7 +18,6 @@ final class ProfileAboutViewModel {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
@Published var account: Mastodon.Entity.Account
@Published var isEditing = false
@Published var accountForEdit: Mastodon.Entity.Account?
@ -32,9 +31,8 @@ final class ProfileAboutViewModel {
@Published var emojiMeta: MastodonContent.Emojis = [:]
@Published var createdAt: Date = Date()
init(context: AppContext, account: Mastodon.Entity.Account) {
init(account: Mastodon.Entity.Account) {
self.account = account
self.context = context
emojiMeta = account.emojiMeta
fields = account.mastodonFields
@ -79,6 +77,12 @@ final class ProfileAboutViewModel {
extension ProfileAboutViewModel {
class ProfileInfo {
@Published var fields: [ProfileFieldItem.FieldValue] = []
var editedFields: [ (String, String) ] {
let edited = fields.map { return ($0.name.value, $0.value.value)
}
return edited
}
}
}
@ -100,7 +104,7 @@ extension ProfileAboutViewModel {
}
// MARK: - ProfileViewModelEditable
extension ProfileAboutViewModel: ProfileViewModelEditable {
extension ProfileAboutViewModel {
var isEdited: Bool {
guard isEditing else { return false }

View File

@ -32,6 +32,9 @@ final class ProfileHeaderViewController: UIViewController, MediaPreviewableViewC
var disposeBag = Set<AnyCancellable>()
let viewModel: ProfileHeaderViewModel
var editedDetails: ProfileHeaderDetails {
return viewModel.editedDetails
}
weak var delegate: ProfileHeaderViewControllerDelegate?
weak var headerDelegate: TabBarPagerHeaderDelegate?
@ -79,9 +82,9 @@ final class ProfileHeaderViewController: UIViewController, MediaPreviewableViewC
return documentPickerController
}()
init(authenticationBox: MastodonAuthenticationBox, profileViewModel: ProfileViewModel) {
self.viewModel = ProfileHeaderViewModel(authenticationBox: authenticationBox, account: profileViewModel.account, me: profileViewModel.me, relationship: profileViewModel.relationship)
self.profileHeaderView = ProfileHeaderView(account: profileViewModel.account, me: profileViewModel.me, relationship: profileViewModel.relationship)
init(authenticationBox: MastodonAuthenticationBox, account: Mastodon.Entity.Account, me: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?) {
self.viewModel = ProfileHeaderViewModel(authenticationBox: authenticationBox, account: account, me: me, relationship: relationship)
self.profileHeaderView = ProfileHeaderView(account: account, me: me, relationship: relationship)
super.init(nibName: nil, bundle: nil)

View File

@ -77,6 +77,10 @@ extension ProfileHeaderViewModel {
@Published var name: String?
@Published var note: String?
}
var editedDetails: ProfileHeaderDetails {
return ProfileHeaderDetails(bannerImage: profileInfoEditing.header, avatarImage: profileInfoEditing.avatar, displayName: profileInfoEditing.name, bioText: profileInfoEditing.note)
}
}
extension ProfileHeaderViewModel {
@ -96,7 +100,7 @@ extension ProfileHeaderViewModel {
}
// MARK: - ProfileViewModelEditable
extension ProfileHeaderViewModel: ProfileViewModelEditable {
extension ProfileHeaderViewModel {
var isEdited: Bool {
guard isEditing else { return false }

View File

@ -475,11 +475,9 @@ extension ProfileHeaderView {
let margin: CGFloat = {
switch traitCollection.userInterfaceIdiom {
case .phone:
return ProfileViewController.containerViewMarginForCompactHorizontalSizeClass
return ProfileViewController.containerViewMargin(forHorizontalSizeClass: .compact)
default:
return traitCollection.horizontalSizeClass == .regular ?
ProfileViewController.containerViewMarginForRegularHorizontalSizeClass :
ProfileViewController.containerViewMarginForCompactHorizontalSizeClass
return ProfileViewController.containerViewMargin(forHorizontalSizeClass: traitCollection.horizontalSizeClass)
}
}()

View File

@ -136,11 +136,9 @@ extension ProfilePagingViewController {
let margin: CGFloat = {
switch traitCollection.userInterfaceIdiom {
case .phone:
return ProfileViewController.containerViewMarginForCompactHorizontalSizeClass
return ProfileViewController.containerViewMargin(forHorizontalSizeClass: .compact)
default:
return traitCollection.horizontalSizeClass == .regular ?
ProfileViewController.containerViewMarginForRegularHorizontalSizeClass :
ProfileViewController.containerViewMarginForCompactHorizontalSizeClass
return ProfileViewController.containerViewMargin(forHorizontalSizeClass: traitCollection.horizontalSizeClass)
}
}()

File diff suppressed because it is too large Load Diff

View File

@ -15,228 +15,124 @@ import MastodonCore
import MastodonLocalization
import MastodonUI
// please override this base class
@MainActor
class ProfileViewModel: NSObject {
typealias UserID = String
var disposeBag = Set<AnyCancellable>()
var observations = Set<NSKeyValueObservation>()
private var mastodonUserObserver: AnyCancellable?
private var currentMastodonUserObserver: AnyCancellable?
let postsUserTimelineViewModel: UserTimelineViewModel
let repliesUserTimelineViewModel: UserTimelineViewModel
let mediaUserTimelineViewModel: UserTimelineViewModel
let profileAboutViewModel: ProfileAboutViewModel
// input
let context: AppContext
let authenticationBox: MastodonAuthenticationBox
enum ServerHostedImage: Equatable {
case fetchable(URL)
// case fetching(URL)
// case fetched(URL, UIImage)
// case fetchError(URL, Error)
case local(UIImage)
}
@Published var me: Mastodon.Entity.Account
@Published var account: Mastodon.Entity.Account
@Published var relationship: Mastodon.Entity.Relationship?
let viewDidAppear = PassthroughSubject<Void, Never>()
struct ProfileHeaderDetails: Equatable {
let bannerImage: ServerHostedImage?
let avatarImage: ServerHostedImage?
let displayName: String?
let bioText: String?
@Published var isEditing = false
@Published var isUpdating = false
@Published var accountForEdit: Mastodon.Entity.Account?
@Published var userIdentifier: UserIdentifier? = nil
@Published var isReplyBarButtonItemHidden: Bool = true
@Published var isMoreMenuBarButtonItemHidden: Bool = true
@Published var isMeBarButtonItemsHidden: Bool = true
@Published var isPagingEnabled = true
// @Published var protected: Bool? = nil
// let needsPagePinToTop = CurrentValueSubject<Bool, Never>(false)
@MainActor
init(context: AppContext, authenticationBox: MastodonAuthenticationBox, account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?, me: Mastodon.Entity.Account) {
self.context = context
self.authenticationBox = authenticationBox
self.account = account
self.relationship = relationship
self.me = me
self.postsUserTimelineViewModel = UserTimelineViewModel(
context: context,
authenticationBox: authenticationBox,
title: L10n.Scene.Profile.SegmentedControl.posts,
queryFilter: .init(excludeReplies: true)
)
self.repliesUserTimelineViewModel = UserTimelineViewModel(
context: context,
authenticationBox: authenticationBox,
title: L10n.Scene.Profile.SegmentedControl.postsAndReplies,
queryFilter: .init(excludeReplies: false)
)
self.mediaUserTimelineViewModel = UserTimelineViewModel(
context: context,
authenticationBox: authenticationBox,
title: L10n.Scene.Profile.SegmentedControl.media,
queryFilter: .init(onlyMedia: true)
)
self.profileAboutViewModel = ProfileAboutViewModel(context: context, account: account)
super.init()
init(_ account: Mastodon.Entity.Account) {
bannerImage = account.headerImageURL().flatMap { .fetchable($0) }
if let domain = account.domain {
userIdentifier = MastodonUserIdentifier(domain: domain, userID: account.id)
avatarImage = .fetchable(account.avatarImageURLWithFallback(domain: domain))
} else {
userIdentifier = nil
avatarImage = account.avatarImageURL().flatMap { .fetchable($0) } // TODO: there is a fallback option here. what is it for?
}
// bind userIdentifier
$userIdentifier.assign(to: &postsUserTimelineViewModel.$userIdentifier)
$userIdentifier.assign(to: &repliesUserTimelineViewModel.$userIdentifier)
$userIdentifier.assign(to: &mediaUserTimelineViewModel.$userIdentifier)
// bind bar button items
Publishers.CombineLatest3($account, $me, $relationship)
.sink(receiveValue: { [weak self] account, me, relationship in
guard let self else {
self?.isReplyBarButtonItemHidden = true
self?.isMoreMenuBarButtonItemHidden = true
self?.isMeBarButtonItemsHidden = true
return
}
let isMyself = (account == me)
self.isReplyBarButtonItemHidden = isMyself
self.isMoreMenuBarButtonItemHidden = isMyself
self.isMeBarButtonItemsHidden = (isMyself == false)
})
.store(in: &disposeBag)
viewDidAppear
.sink { [weak self] _ in
guard let self else { return }
self.isReplyBarButtonItemHidden = self.isReplyBarButtonItemHidden
self.isMoreMenuBarButtonItemHidden = self.isMoreMenuBarButtonItemHidden
self.isMeBarButtonItemsHidden = self.isMeBarButtonItemsHidden
}
.store(in: &disposeBag)
// query relationship
let pendingRetryPublisher = CurrentValueSubject<TimeInterval, Never>(1)
// observe friendship
Publishers.CombineLatest(
$account,
pendingRetryPublisher
)
.sink { [weak self] account, _ in
guard let self else { return }
Task {
do {
let response = try await APIService.shared.relationship(
forAccounts: [account],
authenticationBox: self.authenticationBox
)
// there are seconds delay after request follow before requested -> following. Query again when needs
guard let relationship = response.value.first else { return }
if relationship.requested == true {
let delay = pendingRetryPublisher.value
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let _ = self else { return }
pendingRetryPublisher.value = min(2 * delay, 60)
}
}
} catch {
}
} // end Task
}
.store(in: &disposeBag)
let isBlockingOrBlocked = Publishers.CombineLatest3(
(relationship?.blocking ?? false).publisher,
(relationship?.blockedBy ?? false).publisher,
(relationship?.domainBlocking ?? false).publisher
)
.map { $0 || $1 || $2 }
.share()
Publishers.CombineLatest(
isBlockingOrBlocked,
$isEditing
)
.map { !$0 && !$1 }
.assign(to: &$isPagingEnabled)
displayName = account.displayNameWithFallback
bioText = account.note
}
// fetch profile info before edit
func fetchEditProfileInfo() -> AnyPublisher<Mastodon.Entity.Account, Error> {
guard let domain = me.domain else {
return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher()
}
let mastodonAuthentication = authenticationBox.authentication
let authorization = Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.userAccessToken)
return APIService.shared.verifyAndActivateUser(domain: domain,
clientID: mastodonAuthentication.clientID,
clientSecret: mastodonAuthentication.clientSecret,
authorization: authorization)
.tryMap { (account, _) in
return account
}.eraseToAnyPublisher()
init(bannerImage: UIImage?, avatarImage: UIImage?, displayName: String?, bioText: String?) {
self.bannerImage = bannerImage.flatMap{ .local($0) }
self.avatarImage = avatarImage.flatMap{ .local($0) }
self.displayName = displayName
self.bioText = bioText
}
}
extension ProfileViewModel {
func updateProfileInfo(
headerProfileInfo: ProfileHeaderViewModel.ProfileInfo,
aboutProfileInfo: ProfileAboutViewModel.ProfileInfo
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Account> {
let authenticationBox = authenticationBox
let domain = authenticationBox.domain
let authorization = authenticationBox.userAuthorization
// TODO: constrain size?
let _header: UIImage? = {
guard let image = headerProfileInfo.header else { return nil }
guard image.size.width <= ProfileHeaderViewModel.bannerImageMaxSizeInPixel.width else {
return image.af.imageScaled(to: ProfileHeaderViewModel.bannerImageMaxSizeInPixel)
}
return image
}()
let _avatar: UIImage? = {
guard let image = headerProfileInfo.avatar else { return nil }
guard image.size.width <= ProfileHeaderViewModel.avatarImageMaxSizeInPixel.width else {
return image.af.imageScaled(to: ProfileHeaderViewModel.avatarImageMaxSizeInPixel)
}
return image
}()
let fieldsAttributes = aboutProfileInfo.fields.map { field in
Mastodon.Entity.Field(name: field.name.value, value: field.value.value)
}
let query = Mastodon.API.Account.UpdateCredentialQuery(
discoverable: nil,
bot: nil,
displayName: headerProfileInfo.name,
note: headerProfileInfo.note,
avatar: _avatar.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) },
header: _header.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) },
locked: nil,
source: nil,
fieldsAttributes: fieldsAttributes
)
let response = try await APIService.shared.accountUpdateCredentials(
domain: domain,
query: query,
authorization: authorization
)
NotificationCenter.default.post(name: .userFetched, object: nil)
return response
struct ProfileAboutDetails {
let createdAt: Date
let fields: [ String : String ]
init(_ account: Mastodon.Entity.Account) {
createdAt = account.createdAt
fields = profileFields(account)
}
}
fileprivate func profileFields(_ account: Mastodon.Entity.Account) -> [ String : String ] {
var result = [ String : String ]()
for field in account.fields ?? [] {
result[field.name] = field.value
}
return result
}
public struct ProfileViewModelImmutable {
let profileType: ProfileViewController.ProfileType
let state: ProfileInteractionState
var headerDetails: ProfileHeaderDetails {
return ProfileHeaderDetails(profileType.accountToDisplay)
}
var aboutDetails: ProfileAboutDetails {
return ProfileAboutDetails(profileType.accountToDisplay)
}
public enum ProfileInteractionState {
case idle
case updating
case editing
case pushingEdits
var actionButtonEnabled: Bool {
switch self {
case .updating, .pushingEdits:
return false
case .idle, .editing:
return true
}
}
var isEditing: Bool {
switch self {
case .editing, .pushingEdits:
return true
case .idle, .updating:
return false
}
}
var isUpdating: Bool {
switch self {
case .editing, .idle:
return false
case .pushingEdits, .updating:
return true
}
}
}
var hideReplyBarButtonItem: Bool {
return profileType.isMe
}
var hideMoreMenuBarButtonItem: Bool {
return profileType.isMe
}
var hideIsMeBarButtonItems: Bool {
return !profileType.isMe
}
var isPagingEnabled: Bool {
guard !state.isEditing else { return false }
guard let relationship = profileType.myRelationshipToDisplayedAccount else { return true }
return !relationship.isBlockingOrBlocked
}
}
fileprivate extension Mastodon.Entity.Relationship {
var isBlockingOrBlocked: Bool {
return blocking || blockedBy || domainBlocking
}
}

View File

@ -18,7 +18,6 @@ final class UserTimelineViewModel {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let authenticationBox: MastodonAuthenticationBox
let title: String
let dataController: StatusDataController
@ -50,12 +49,10 @@ final class UserTimelineViewModel {
@MainActor
init(
context: AppContext,
authenticationBox: MastodonAuthenticationBox,
title: String,
queryFilter: QueryFilter
) {
self.context = context
self.authenticationBox = authenticationBox
self.title = title
self.dataController = StatusDataController()

View File

@ -36,7 +36,7 @@ class MainTabBarController: UITabBarController {
let searchViewController: SearchViewController
let composeViewController: UIViewController // placeholder
let notificationViewController: NotificationViewController
let meProfileViewController: ProfileViewController
var meProfileViewController: UIViewController // placeholder
private(set) var isReadyForWizardAvatarButton = false
@ -63,17 +63,13 @@ class MainTabBarController: UITabBarController {
notificationViewController = NotificationViewController()
notificationViewController.configureTabBarItem(with: .notifications)
meProfileViewController = ProfileViewController()
meProfileViewController = UIViewController()
meProfileViewController.configureTabBarItem(with: .me)
if let authenticationBox {
notificationViewController.viewModel = NotificationViewModel(context: AppContext.shared, authenticationBox: authenticationBox)
homeTimelineViewController.viewModel = HomeTimelineViewModel(authenticationBox: authenticationBox)
searchViewController.viewModel = SearchViewModel(authenticationBox: authenticationBox)
if let account = authenticationBox.cachedAccount {
meProfileViewController.viewModel = ProfileViewModel(context: AppContext.shared, authenticationBox: authenticationBox, account: account, relationship: nil, me: account)
}
}
super.init(nibName: nil, bundle: nil)
@ -84,6 +80,12 @@ class MainTabBarController: UITabBarController {
layoutAvatarButton()
}
private func replace(_ oldVC: UIViewController, with newVC: UIViewController) {
guard let navControllers = viewControllers as? [UINavigationController] else { return }
guard let toReplace = navControllers.first(where: { $0.viewControllers[0] == oldVC }) else { return }
toReplace.viewControllers = [newVC]
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
@ -93,16 +95,6 @@ extension MainTabBarController {
return selectedViewController
}
override var selectedViewController: UIViewController? {
willSet {
if let profileView = (newValue as? UINavigationController)?.topViewController as? ProfileViewController{
guard let authenticationBox,
let account = authenticationBox.cachedAccount else { return }
profileView.viewModel = ProfileViewModel(context: AppContext.shared, authenticationBox: authenticationBox, account: account, relationship: nil, me: account)
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
@ -171,6 +163,30 @@ extension MainTabBarController {
notificationViewController.navigationController?.tabBarItem.image = image.imageWithoutBaseline()
}
.store(in: &disposeBag)
$currentTab
.receive(on: DispatchQueue.main)
.sink { [weak self] currentTab in
guard let self else { return }
if currentTab == .me {
guard let authBox = authenticationBox, let myAccount = authBox.cachedAccount else { return }
let oldMe = meProfileViewController
let updatedProfile = ProfileViewController(.me(myAccount), authenticationBox: authBox)
meProfileViewController = updatedProfile
updatedProfile.configureTabBarItem(with: .me)
self.replace(oldMe, with: updatedProfile)
if let domain = myAccount.domain ?? myAccount.domainFromAcct {
self.avatarURL = myAccount.avatarImageURLWithFallback(domain: domain)
} else {
self.avatarURL = myAccount.avatarImageURL()
}
self.avatarButton.removeFromSuperview()
self.layoutAvatarButton()
}
}
.store(in: &disposeBag)
$avatarURL
.receive(on: DispatchQueue.main)
@ -203,10 +219,6 @@ extension MainTabBarController {
self?.updateUserAccount()
}
.store(in: &self.disposeBag)
if let currentViewModel = self.meProfileViewController.viewModel, currentViewModel.account.id == account.id, !currentViewModel.isEditing {
self.meProfileViewController.viewModel = ProfileViewModel(context: AppContext.shared, authenticationBox: authenticationBox, account: account, relationship: nil, me: account)
}
}
.store(in: &disposeBag)
@ -330,6 +342,7 @@ extension MainTabBarController {
}
anchorImageView.alpha = 0
accountSwitcherChevron.removeFromSuperview()
accountSwitcherChevron.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(accountSwitcherChevron)

View File

@ -167,15 +167,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
authenticationBox: authenticationBox
).value.first else { return }
let profileViewModel = ProfileViewModel(
context: AppContext.shared,
authenticationBox: authenticationBox,
account: account,
relationship: relationship,
me: me
)
let profileType: ProfileViewController.ProfileType = me == account ? .me(me) : .notMe(me: me, displayAccount: account, relationship: relationship)
_ = self.coordinator?.present(
scene: .profile(viewModel: profileViewModel),
scene: .profile(profileType),
from: nil,
transition: .show
)
@ -308,16 +302,9 @@ extension SceneDelegate {
authenticationBox: authenticationBox
).value.first else { return }
let profileViewModel = ProfileViewModel(
context: AppContext.shared,
authenticationBox: authenticationBox,
account: account,
relationship: relationship,
me: me
)
let profileType: ProfileViewController.ProfileType = me == account ? .me(me) : .notMe(me: me, displayAccount: account, relationship: relationship)
self.coordinator?.present(
scene: .profile(viewModel: profileViewModel),
scene: .profile(profileType),
from: nil,
transition: .show
)