fix: refactor the profile UI to fix internal AutoLayout crash issue. resolve #440

This commit is contained in:
CMK 2022-05-26 23:19:47 +08:00
parent 503fcfab2a
commit ad63c512df
23 changed files with 1553 additions and 1764 deletions

View File

@ -145,6 +145,8 @@
DB0EF72B26FDB1D200347686 /* SidebarListCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0EF72A26FDB1D200347686 /* SidebarListCollectionViewCell.swift */; };
DB0EF72E26FDB24F00347686 /* SidebarListContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0EF72D26FDB24F00347686 /* SidebarListContentView.swift */; };
DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */; };
DB0F9D54283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F9D53283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift */; };
DB0F9D56283EB46200379AF8 /* ProfileHeaderView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F9D55283EB46200379AF8 /* ProfileHeaderView+Configuration.swift */; };
DB0FCB68279507EF006C02E2 /* DataSourceFacade+Meta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */; };
DB0FCB6C27950E29006C02E2 /* MastodonMentionContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB6B27950E29006C02E2 /* MastodonMentionContainer.swift */; };
DB0FCB6E27950E6B006C02E2 /* MastodonMention.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB6D27950E6B006C02E2 /* MastodonMention.swift */; };
@ -508,7 +510,6 @@
DBB45B6027B50A4F002DC5A7 /* RecommendAccountItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */; };
DBB45B6227B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */; };
DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; };
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; };
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; };
DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */; };
DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */; };
@ -889,6 +890,8 @@
DB0EF72D26FDB24F00347686 /* SidebarListContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListContentView.swift; sourceTree = "<group>"; };
DB0F814E264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = "<group>"; };
DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerLoaderTableViewCell.swift; sourceTree = "<group>"; };
DB0F9D53283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileHeaderView+ViewModel.swift"; sourceTree = "<group>"; };
DB0F9D55283EB46200379AF8 /* ProfileHeaderView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileHeaderView+Configuration.swift"; sourceTree = "<group>"; };
DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Meta.swift"; sourceTree = "<group>"; };
DB0FCB6B27950E29006C02E2 /* MastodonMentionContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMentionContainer.swift; sourceTree = "<group>"; };
DB0FCB6D27950E6B006C02E2 /* MastodonMention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMention.swift; sourceTree = "<group>"; };
@ -1286,7 +1289,6 @@
DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionViewController.swift; sourceTree = "<group>"; };
DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountItem.swift; sourceTree = "<group>"; };
DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountViewModel+Diffable.swift"; sourceTree = "<group>"; };
DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = "<group>"; };
DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = "<group>"; };
DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = "<group>"; };
DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewController.swift; sourceTree = "<group>"; };
@ -3006,8 +3008,8 @@
DB9D6C0825E4F5A60051B173 /* Profile */ = {
isa = PBXGroup;
children = (
DBB525132611EBB1002F1F29 /* Segmented */,
DBB525462611ED57002F1F29 /* Header */,
DBB525262611EBDA002F1F29 /* Paging */,
DBB5253B2611ECF5002F1F29 /* Timeline */,
DBE3CDF1261C6B3100430CC6 /* Favorite */,
DB6B74F0272FB55400C70B6E /* Follower */,
@ -3106,15 +3108,6 @@
path = Video;
sourceTree = "<group>";
};
DBB525132611EBB1002F1F29 /* Segmented */ = {
isa = PBXGroup;
children = (
DBB525262611EBDA002F1F29 /* Paging */,
DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */,
);
path = Segmented;
sourceTree = "<group>";
};
DBB525262611EBDA002F1F29 /* Paging */ = {
isa = PBXGroup;
children = (
@ -3150,6 +3143,8 @@
isa = PBXGroup;
children = (
DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */,
DB0F9D53283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift */,
DB0F9D55283EB46200379AF8 /* ProfileHeaderView+Configuration.swift */,
DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */,
);
path = View;
@ -4041,7 +4036,6 @@
DB0FCB7C2795821F006C02E2 /* StatusThreadRootTableViewCell.swift in Sources */,
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */,
DB5B54A32833BD1A00DEF8B2 /* UserListViewModel.swift in Sources */,
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */,
DBF156DF2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift in Sources */,
DB0617F1278413D00030EE79 /* PickServerServerSectionTableHeaderView.swift in Sources */,
DB0FCB7E27958957006C02E2 /* StatusThreadRootTableViewCell+ViewModel.swift in Sources */,
@ -4398,6 +4392,7 @@
DB67D08627312E67006A36CF /* WizardViewController.swift in Sources */,
DB6746EB278ED8B0008A6B94 /* PollOptionView+Configuration.swift in Sources */,
DB9A489026035963008B817C /* APIService+Media.swift in Sources */,
DB0F9D54283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift in Sources */,
DBFEEC99279BDCDE004F81DD /* ProfileAboutViewModel.swift in Sources */,
2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */,
DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */,
@ -4406,6 +4401,7 @@
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */,
DB3E6FF12806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift in Sources */,
DB3E6FF82807C45300B035AE /* DiscoveryForYouViewModel.swift in Sources */,
DB0F9D56283EB46200379AF8 /* ProfileHeaderView+Configuration.swift in Sources */,
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */,
DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */,
DB6D9F6F2635807F008423CD /* Setting.swift in Sources */,

View File

@ -24,7 +24,7 @@
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>8</integer>
<integer>7</integer>
</dict>
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
<dict>
@ -114,7 +114,7 @@
<key>MastodonIntent.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>28</integer>
<integer>23</integer>
</dict>
<key>MastodonIntents.xcscheme_^#shared#^_</key>
<dict>
@ -129,12 +129,12 @@
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>29</integer>
<integer>22</integer>
</dict>
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>27</integer>
<integer>24</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -342,6 +342,7 @@ extension SceneCoordinator {
case .custom(let transitioningDelegate):
viewController.modalPresentationStyle = .custom
viewController.transitioningDelegate = transitioningDelegate
// viewController.modalPresentationCapturesStatusBarAppearance = true
(splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil)
case .customPush(let animated):

View File

@ -135,6 +135,18 @@ extension MediaPreviewViewController {
}
}
.store(in: &disposeBag)
// viewModel.$isPoping
// .receive(on: DispatchQueue.main)
// .removeDuplicates()
// .sink { [weak self] _ in
// guard let self = self else { return }
// // statusBar style update with animation
// self.setNeedsStatusBarAppearanceUpdate()
// UIView.animate(withDuration: 0.3) {
// }
// }
// .store(in: &disposeBag)
}
}

View File

@ -25,7 +25,8 @@ extension ProfileAboutViewModel {
profileFieldEditCollectionViewCellDelegate: profileFieldEditCollectionViewCellDelegate
)
)
self.diffableDataSource = diffableDataSource
diffableDataSource.reorderingHandlers.canReorderItem = { item -> Bool in
switch item {
case .editField: return true
@ -42,22 +43,25 @@ extension ProfileAboutViewModel {
guard case let .editField(field) = item else { continue }
fields.append(field)
}
self.editProfileInfo.fields = fields
self.profileInfoEditing.fields = fields
}
self.diffableDataSource = diffableDataSource
var snapshot = NSDiffableDataSourceSnapshot<ProfileFieldSection, ProfileFieldItem>()
snapshot.appendSections([.main])
diffableDataSource.apply(snapshot)
Publishers.CombineLatest4(
$isEditing.removeDuplicates(),
displayProfileInfo.$fields.removeDuplicates(),
editProfileInfo.$fields.removeDuplicates(),
profileInfo.$fields.removeDuplicates(),
profileInfoEditing.$fields.removeDuplicates(),
$emojiMeta.removeDuplicates()
)
.throttle(for: 0.3, scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] isEditing, displayFields, editingFields, emojiMeta in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<ProfileFieldSection, ProfileFieldItem>()
snapshot.appendSections([.main])
@ -69,17 +73,17 @@ extension ProfileAboutViewModel {
return ProfileFieldItem.field(field: field)
}
}
if isEditing, fields.count < ProfileHeaderViewModel.maxProfileFieldCount {
items.append(.addEntry)
}
if !isEditing, items.isEmpty {
items.append(.noResult)
}
snapshot.appendItems(items, toSection: .main)
diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
.store(in: &disposeBag)

View File

@ -8,6 +8,7 @@
import os.log
import UIKit
import Combine
import CoreDataStack
import MastodonSDK
import MastodonMeta
import Kanna
@ -18,41 +19,69 @@ final class ProfileAboutViewModel {
// input
let context: AppContext
@Published var user: MastodonUser?
@Published var isEditing = false
@Published var accountForEdit: Mastodon.Entity.Account?
@Published var emojiMeta: MastodonContent.Emojis = [:]
// output
var diffableDataSource: UICollectionViewDiffableDataSource<ProfileFieldSection, ProfileFieldItem>?
let profileInfo = ProfileInfo()
let profileInfoEditing = ProfileInfo()
let displayProfileInfo = ProfileInfo()
let editProfileInfo = ProfileInfo()
let editProfileInfoDidInitialized = CurrentValueSubject<Void, Never>(Void()) // needs trigger initial event
@Published var fields: [MastodonField] = []
@Published var emojiMeta: MastodonContent.Emojis = [:]
init(context: AppContext) {
self.context = context
// end init
$user
.compactMap { $0 }
.flatMap { $0.publisher(for: \.emojis) }
.map { $0.asDictionary }
.assign(to: &$emojiMeta)
$user
.compactMap { $0 }
.flatMap { $0.publisher(for: \.fields) }
.assign(to: &$fields)
Publishers.CombineLatest(
$isEditing.removeDuplicates(), // only trigger when value toggle
$accountForEdit
$fields,
$emojiMeta
)
.map { fields, emojiMeta in
fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value, emojiMeta: emojiMeta) }
}
.assign(to: &profileInfo.$fields)
Publishers.CombineLatest(
$accountForEdit,
$emojiMeta
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing, account in
.sink { [weak self] account, emojiMeta in
guard let self = self else { return }
guard isEditing else { return }
guard let account = account else { return }
// setup editing value when toggle to editing
self.editProfileInfo.fields = account?.source?.fields?.compactMap { field in
self.profileInfo.fields = account.source?.fields?.compactMap { field in
ProfileFieldItem.FieldValue(
name: field.name,
value: field.value,
emojiMeta: emojiMeta
)
} ?? []
self.profileInfoEditing.fields = account.source?.fields?.compactMap { field in
ProfileFieldItem.FieldValue(
name: field.name,
value: field.value,
emojiMeta: [:] // no use for editing
)
} ?? []
self.editProfileInfoDidInitialized.send()
}
.store(in: &disposeBag)
}
}
@ -65,31 +94,31 @@ extension ProfileAboutViewModel {
extension ProfileAboutViewModel {
func appendFieldItem() {
var fields = editProfileInfo.fields
var fields = profileInfoEditing.fields
guard fields.count < ProfileHeaderViewModel.maxProfileFieldCount else { return }
fields.append(ProfileFieldItem.FieldValue(name: "", value: "", emojiMeta: [:]))
editProfileInfo.fields = fields
profileInfoEditing.fields = fields
}
func removeFieldItem(item: ProfileFieldItem) {
var fields = editProfileInfo.fields
var fields = profileInfoEditing.fields
guard case let .editField(field) = item else { return }
guard let removeIndex = fields.firstIndex(of: field) else { return }
fields.remove(at: removeIndex)
editProfileInfo.fields = fields
profileInfoEditing.fields = fields
}
}
// MARK: - ProfileViewModelEditable
extension ProfileAboutViewModel: ProfileViewModelEditable {
func isEdited() -> Bool {
var isEdited: Bool {
guard isEditing else { return false }
let isFieldsEqual: Bool = {
let originalFields = self.accountForEdit?.source?.fields?.compactMap { field in
ProfileFieldItem.FieldValue(name: field.name, value: field.value, emojiMeta: [:])
} ?? []
let editFields = editProfileInfo.fields
let editFields = profileInfoEditing.fields
guard editFields.count == originalFields.count else { return false }
for (editField, originalField) in zip(editFields, originalFields) {
guard editField.name.value == originalField.name.value,

View File

@ -8,6 +8,7 @@
import os.log
import UIKit
import Combine
import CoreDataStack
import PhotosUI
import AlamofireImage
import CropViewController
@ -18,19 +19,27 @@ import MastodonLocalization
import TabBarPager
protocol ProfileHeaderViewControllerDelegate: AnyObject {
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView)
func profileHeaderViewController(_ profileHeaderViewController: ProfileHeaderViewController, profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton)
func profileHeaderViewController(_ profileHeaderViewController: ProfileHeaderViewController, profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta)
}
final class ProfileHeaderViewController: UIViewController {
final class ProfileHeaderViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
let logger = Logger(subsystem: "ProfileHeaderViewController", category: "ViewController")
static let segmentedControlHeight: CGFloat = 50
static let headerMinHeight: CGFloat = segmentedControlHeight
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: ProfileHeaderViewModel!
weak var delegate: ProfileHeaderViewControllerDelegate?
weak var headerDelegate: TabBarPagerHeaderDelegate?
var viewModel: ProfileHeaderViewModel!
let mediaPreviewTransitionController = MediaPreviewTransitionController()
let titleView: DoubleTitleLabelNavigationBarTitleView = {
let titleView = DoubleTitleLabelNavigationBarTitleView()
@ -44,39 +53,8 @@ final class ProfileHeaderViewController: UIViewController {
}()
let profileHeaderView = ProfileHeaderView()
// let buttonBar: TMBar.ButtonBar = {
// let buttonBar = TMBar.ButtonBar()
// buttonBar.indicator.backgroundColor = Asset.Colors.Label.primary.color
// buttonBar.backgroundView.style = .clear
// buttonBar.layout.contentInset = .zero
// return buttonBar
// }()
// func customizeButtonBarAppearance() {
// // The implmention use CATextlayer. Adapt for Dark Mode without dynamic colors
// // Needs trigger update when `userInterfaceStyle` chagnes
// let userInterfaceStyle = traitCollection.userInterfaceStyle
// buttonBar.buttons.customize { button in
// switch userInterfaceStyle {
// case .dark:
// // Asset.Colors.Label.primary.color
// button.selectedTintColor = UIColor(red: 238.0/255.0, green: 238.0/255.0, blue: 238.0/255.0, alpha: 1.0)
// // Asset.Colors.Label.secondary.color
// button.tintColor = UIColor(red: 151.0/255.0, green: 157.0/255.0, blue: 173.0/255.0, alpha: 1.0)
// default:
// // Asset.Colors.Label.primary.color
// button.selectedTintColor = UIColor(red: 40.0/255.0, green: 44.0/255.0, blue: 55.0/255.0, alpha: 1.0)
// // Asset.Colors.Label.secondary.color
// button.tintColor = UIColor(red: 60.0/255.0, green: 60.0/255.0, blue: 67.0/255.0, alpha: 0.6)
// }
//
// button.backgroundColor = .clear
// }
// }
private var isBannerPinned = false
private var bottomShadowAlpha: CGFloat = 0.0
// private var isBannerPinned = false
// private var isAdjustBannerImageViewForSafeAreaInset = false
private var containerSafeAreaInset: UIEdgeInsets = .zero
@ -104,7 +82,7 @@ final class ProfileHeaderViewController: UIViewController {
}()
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
@ -114,7 +92,7 @@ extension ProfileHeaderViewController {
override func viewDidLoad() {
super.viewDidLoad()
// customizeButtonBarAppearance()
view.setContentHuggingPriority(.required - 1, for: .vertical)
view.backgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor
ThemeService.shared.currentTheme
@ -125,6 +103,7 @@ extension ProfileHeaderViewController {
}
.store(in: &disposeBag)
// profileHeaderView.preservesSuperviewLayoutMargins = true
profileHeaderView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(profileHeaderView)
NSLayoutConstraint.activate([
@ -133,130 +112,64 @@ extension ProfileHeaderViewController {
profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
view.bottomAnchor.constraint(equalTo: profileHeaderView.bottomAnchor),
])
profileHeaderView.preservesSuperviewLayoutMargins = true
Publishers.CombineLatest(
viewModel.viewDidAppear.eraseToAnyPublisher(),
viewModel.isTitleViewContentOffsetSet.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] viewDidAppear, isTitleViewContentOffsetDidSet in
guard let self = self else { return }
self.titleView.titleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSet ? 1 : 0
self.titleView.subtitleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSet ? 1 : 0
}
.store(in: &disposeBag)
viewModel.needsSetupBottomShadow
.receive(on: DispatchQueue.main)
.sink { [weak self] needsSetupBottomShadow in
guard let self = self else { return }
self.setupBottomShadow()
}
.store(in: &disposeBag)
Publishers.CombineLatest4(
viewModel.$isEditing.eraseToAnyPublisher(),
viewModel.displayProfileInfo.$avatarImageResource.eraseToAnyPublisher(),
viewModel.editProfileInfo.$avatarImageResource.eraseToAnyPublisher(),
viewModel.viewDidAppear.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing, displayResource, editingResource, _ in
guard let self = self else { return }
let url = displayResource.url
let image = editingResource.image
self.profileHeaderView.avatarButton.avatarImageView.configure(
configuration: AvatarImageView.Configuration(
url: isEditing && image != nil ? nil : url,
placeholder: image ?? UIImage.placeholder(color: Asset.Theme.Mastodon.systemGroupedBackground.color)
)
)
}
.store(in: &disposeBag)
Publishers.CombineLatest4(
viewModel.$isEditing,
viewModel.displayProfileInfo.$name.removeDuplicates(),
viewModel.editProfileInfo.$name.removeDuplicates(),
viewModel.$emojiMeta
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing, name, editingName, emojiMeta in
guard let self = self else { return }
do {
let mastodonContent = MastodonContent(content: name ?? " ", emojis: emojiMeta)
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
self.profileHeaderView.nameMetaText.configure(content: metaContent)
} catch {
assertionFailure()
}
self.profileHeaderView.nameTextField.text = isEditing ? editingName : name
}
.store(in: &disposeBag)
let profileNote = Publishers.CombineLatest3(
viewModel.$isEditing.removeDuplicates(),
viewModel.displayProfileInfo.$note.removeDuplicates(),
viewModel.editProfileInfoDidInitialized
)
.map { isEditing, displayNote, _ -> String? in
if isEditing {
return self.viewModel.editProfileInfo.note
} else {
return displayNote
}
}
.eraseToAnyPublisher()
Publishers.CombineLatest3(
viewModel.$isEditing.removeDuplicates(),
profileNote.removeDuplicates(),
viewModel.$emojiMeta.removeDuplicates()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing, note, emojiMeta in
guard let self = self else { return }
self.profileHeaderView.bioMetaText.textView.isEditable = isEditing
if isEditing {
let metaContent = PlaintextMetaContent(string: note ?? "")
self.profileHeaderView.bioMetaText.configure(content: metaContent)
} else {
let mastodonContent = MastodonContent(content: note ?? "", emojis: emojiMeta)
do {
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
self.profileHeaderView.bioMetaText.configure(content: metaContent)
} catch {
assertionFailure()
self.profileHeaderView.bioMetaText.reset()
}
}
}
.store(in: &disposeBag)
profileHeaderView.bioMetaText.delegate = self
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: profileHeaderView.nameTextField)
.receive(on: DispatchQueue.main)
.sink { [weak self] notification in
guard let self = self else { return }
guard let textField = notification.object as? UITextField else { return }
self.viewModel.editProfileInfo.name = textField.text
self.viewModel.profileInfoEditing.name = textField.text
}
.store(in: &disposeBag)
profileHeaderView.editAvatarButton.menu = createAvatarContextMenu()
profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true
profileHeaderView.editAvatarButtonOverlayIndicatorView.menu = createAvatarContextMenu()
profileHeaderView.editAvatarButtonOverlayIndicatorView.showsMenuAsPrimaryAction = true
profileHeaderView.delegate = self
// bind viewModel
viewModel.$isTitleViewContentOffsetSet
.receive(on: DispatchQueue.main)
.sink { [weak self] isTitleViewContentOffsetDidSet in
guard let self = self else { return }
self.titleView.titleLabel.alpha = isTitleViewContentOffsetDidSet ? 1 : 0
self.titleView.subtitleLabel.alpha = isTitleViewContentOffsetDidSet ? 1 : 0
}
.store(in: &disposeBag)
viewModel.$user
.receive(on: DispatchQueue.main)
.sink { [weak self] user in
guard let self = self else { return }
guard let user = user else { return }
self.profileHeaderView.prepareForReuse()
self.profileHeaderView.configuration(user: user)
}
.store(in: &disposeBag)
viewModel.$relationshipActionOptionSet
.assign(to: \.relationshipActionOptionSet, on: profileHeaderView.viewModel)
.store(in: &disposeBag)
viewModel.$isEditing
.assign(to: \.isEditing, on: profileHeaderView.viewModel)
.store(in: &disposeBag)
viewModel.$isUpdating
.assign(to: \.isUpdating, on: profileHeaderView.viewModel)
.store(in: &disposeBag)
viewModel.profileInfoEditing.$avatar
.assign(to: \.avatarImageEditing, on: profileHeaderView.viewModel)
.store(in: &disposeBag)
viewModel.profileInfoEditing.$name
.assign(to: \.nameEditing, on: profileHeaderView.viewModel)
.store(in: &disposeBag)
viewModel.profileInfoEditing.$note
.assign(to: \.noteEditing, on: profileHeaderView.viewModel)
.store(in: &disposeBag)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppear.value = true
profileHeaderView.viewModel.viewDidAppear.send()
// set display after view appear
profileHeaderView.setupAvatarOverlayViews()
}
@ -264,19 +177,7 @@ extension ProfileHeaderViewController {
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
switch UIApplication.shared.applicationState {
case .active:
headerDelegate?.viewLayoutDidUpdate(self)
setupBottomShadow()
default:
break
}
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
// customizeButtonBarAppearance()
headerDelegate?.viewLayoutDidUpdate(self)
}
}
@ -328,80 +229,28 @@ extension ProfileHeaderViewController {
containerSafeAreaInset = inset
}
func setupBottomShadow() {
guard viewModel.needsSetupBottomShadow.value else {
view.layer.shadowColor = nil
view.layer.shadowRadius = 0
return
}
view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero)
}
private func updateHeaderBottomShadow(progress: CGFloat) {
let alpha = min(max(0, 10 * progress - 9), 1)
if bottomShadowAlpha != alpha {
bottomShadowAlpha = alpha
view.setNeedsLayout()
func updateHeaderScrollProgress(_ progress: CGFloat, throttle: CGFloat) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress)
// set title view offset
let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil)
let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y
let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset
let transformY = max(0, titleViewContentOffset)
titleView.containerView.transform = CGAffineTransform(translationX: 0, y: transformY)
viewModel.isTitleViewDisplaying = transformY < titleView.containerView.frame.height
viewModel.isTitleViewContentOffsetSet = true
if progress > 0, throttle > 0 {
// y = 1 - (x/t)
// give: x = 0, y = 1
// x = t, y = 0
let alpha = 1 - progress/throttle
setProfileAvatar(alpha: alpha)
} else {
setProfileAvatar(alpha: 1)
}
}
// func updateHeaderScrollProgress(_ progress: CGFloat, throttle: CGFloat) {
// // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress)
// updateHeaderBottomShadow(progress: progress)
//
// let bannerImageView = profileHeaderView.bannerImageView
// guard bannerImageView.bounds != .zero else {
// // wait layout finish
// return
// }
//
// let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil)
// let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height
//
// // scroll from bottom to top: 1 -> 2 -> 3
// if bannerContainerInWindow.origin.y > containerSafeAreaInset.top {
// // 1
// // banner top pin to window top and expand
// bannerImageView.frame.origin.y = -bannerContainerInWindow.origin.y
// bannerImageView.frame.size.height = bannerContainerInWindow.origin.y + bannerContainerInWindow.size.height
// } else if bannerContainerBottomOffset < containerSafeAreaInset.top {
// // 3
// // banner bottom pin to navigation bar bottom and
// // the `progress` growth to 1 then segmented control pin to top
// bannerImageView.frame.origin.y = -containerSafeAreaInset.top
// let bannerImageHeight = bannerContainerInWindow.size.height + containerSafeAreaInset.top + (containerSafeAreaInset.top - bannerContainerBottomOffset)
// bannerImageView.frame.size.height = bannerImageHeight
// } else {
// // 2
// // banner move with scrolling from bottom to top until the
// // banner bottom higher than navigation bar bottom
// bannerImageView.frame.origin.y = -containerSafeAreaInset.top
// bannerImageView.frame.size.height = bannerContainerInWindow.size.height + containerSafeAreaInset.top
// }
//
// // set title view offset
// let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil)
// let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y
// let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset
// let transformY = max(0, titleViewContentOffset)
// titleView.containerView.transform = CGAffineTransform(translationX: 0, y: transformY)
// viewModel.isTitleViewDisplaying.value = transformY < titleView.containerView.frame.height
//
// if viewModel.viewDidAppear.value {
// viewModel.isTitleViewContentOffsetSet.value = true
// }
//
// // set avatar fade
// if progress > 0 {
// setProfileAvatar(alpha: 0)
// } else if progress > -abs(throttle) {
// // y = -(1/0.8T)x
// let alpha = -1 / abs(0.8 * throttle) * progress
// setProfileAvatar(alpha: alpha)
// } else {
// setProfileAvatar(alpha: 1)
// }
// }
private func setProfileAvatar(alpha: CGFloat) {
profileHeaderView.avatarImageViewBackgroundView.alpha = alpha
@ -411,6 +260,103 @@ extension ProfileHeaderViewController {
}
// MARK: - ProfileHeaderViewDelegate
extension ProfileHeaderViewController: ProfileHeaderViewDelegate {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarButtonDidPressed button: AvatarButton) {
guard let user = viewModel.user else { return }
let record: ManagedObjectRecord<MastodonUser> = .init(objectID: user.objectID)
Task {
try await DataSourceFacade.coordinateToMediaPreviewScene(
dependency: self,
user: record,
previewContext: DataSourceFacade.ImagePreviewContext(
imageView: button.avatarImageView,
containerView: .profileAvatar(profileHeaderView)
)
)
} // end Task
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) {
guard let user = viewModel.user else { return }
let record: ManagedObjectRecord<MastodonUser> = .init(objectID: user.objectID)
Task {
try await DataSourceFacade.coordinateToMediaPreviewScene(
dependency: self,
user: record,
previewContext: DataSourceFacade.ImagePreviewContext(
imageView: imageView,
containerView: .profileBanner(profileHeaderView)
)
)
} // end Task
}
func profileHeaderView(
_ profileHeaderView: ProfileHeaderView,
relationshipButtonDidPressed button: ProfileRelationshipActionButton
) {
delegate?.profileHeaderViewController(
self,
profileHeaderView: profileHeaderView,
relationshipButtonDidPressed: button
)
}
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta) {
delegate?.profileHeaderViewController(
self,
profileHeaderView: profileHeaderView,
metaTextView: metaTextView,
metaDidPressed: meta
)
}
func profileHeaderView(
_ profileHeaderView: ProfileHeaderView,
profileStatusDashboardView dashboardView: ProfileStatusDashboardView,
dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView,
meter: ProfileStatusDashboardView.Meter
) {
switch meter {
case .post:
// do nothing
break
case .follower:
guard let domain = viewModel.user?.domain,
let userID = viewModel.user?.id
else { return }
let followerListViewModel = FollowerListViewModel(
context: context,
domain: domain,
userID: userID
)
coordinator.present(
scene: .follower(viewModel: followerListViewModel),
from: self,
transition: .show
)
case .following:
guard let domain = viewModel.user?.domain,
let userID = viewModel.user?.id
else { return }
let followingListViewModel = FollowingListViewModel(
context: context,
domain: domain,
userID: userID
)
coordinator.present(
scene: .following(viewModel: followingListViewModel),
from: self,
transition: .show
)
}
}
}
// MARK: - MetaTextDelegate
extension ProfileHeaderViewController: MetaTextDelegate {
func metaText(_ metaText: MetaText, processEditing textStorage: MetaTextStorage) -> MetaContent? {
@ -419,7 +365,9 @@ extension ProfileHeaderViewController: MetaTextDelegate {
switch metaText {
case profileHeaderView.bioMetaText:
guard viewModel.isEditing else { break }
viewModel.editProfileInfo.note = metaText.backedString
defer {
viewModel.profileInfoEditing.note = metaText.backedString
}
let metaContent = PlaintextMetaContent(string: metaText.backedString)
return metaContent
default:
@ -491,7 +439,7 @@ extension ProfileHeaderViewController: UIDocumentPickerDelegate {
// MARK: - CropViewControllerDelegate
extension ProfileHeaderViewController: CropViewControllerDelegate {
public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) {
viewModel.editProfileInfo.avatarImage = image
viewModel.profileInfoEditing.avatar = image
cropViewController.dismiss(animated: true, completion: nil)
}
}

View File

@ -8,9 +8,11 @@
import os.log
import UIKit
import Combine
import CoreDataStack
import Kanna
import MastodonSDK
import MastodonMeta
import MastodonUI
final class ProfileHeaderViewModel {
@ -21,39 +23,44 @@ final class ProfileHeaderViewModel {
// input
let context: AppContext
@Published var user: MastodonUser?
@Published var relationshipActionOptionSet: RelationshipActionOptionSet = .none
@Published var isEditing = false
@Published var accountForEdit: Mastodon.Entity.Account?
@Published var emojiMeta: MastodonContent.Emojis = [:]
@Published var isUpdating = false
let viewDidAppear = CurrentValueSubject<Bool, Never>(false)
let needsSetupBottomShadow = CurrentValueSubject<Bool, Never>(true)
let needsFiledCollectionViewHidden = CurrentValueSubject<Bool, Never>(false)
let isTitleViewContentOffsetSet = CurrentValueSubject<Bool, Never>(false)
@Published var accountForEdit: Mastodon.Entity.Account?
// let needsFiledCollectionViewHidden = CurrentValueSubject<Bool, Never>(false)
// output
let isTitleViewDisplaying = CurrentValueSubject<Bool, Never>(false)
let displayProfileInfo = ProfileInfo()
let editProfileInfo = ProfileInfo()
let editProfileInfoDidInitialized = CurrentValueSubject<Void, Never>(Void()) // needs trigger initial event
let profileInfo = ProfileInfo()
let profileInfoEditing = ProfileInfo()
@Published var isTitleViewDisplaying = false
@Published var isTitleViewContentOffsetSet = false
init(context: AppContext) {
self.context = context
Publishers.CombineLatest(
$isEditing.removeDuplicates(), // only trigger when value toggle
$accountForEdit
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing, account in
guard let self = self else { return }
guard isEditing else { return }
// setup editing value when toggle to editing
self.editProfileInfo.name = self.displayProfileInfo.name // set to name
self.editProfileInfo.avatarImage = nil // set to empty
self.editProfileInfo.note = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note)
self.editProfileInfoDidInitialized.send()
}
.store(in: &disposeBag)
$accountForEdit
.receive(on: DispatchQueue.main)
.sink { [weak self] account in
guard let self = self else { return }
guard let account = account else { return }
// avatar
self.profileInfo.avatar = nil
self.profileInfoEditing.avatar = nil
// name
let name = account.displayNameWithFallback
self.profileInfo.name = name
self.profileInfoEditing.name = name
// bio
let note = ProfileHeaderViewModel.normalize(note: account.note)
self.profileInfo.note = note
self.profileInfoEditing.note = note
}
.store(in: &disposeBag)
}
}
@ -61,29 +68,9 @@ final class ProfileHeaderViewModel {
extension ProfileHeaderViewModel {
class ProfileInfo {
// input
@Published var avatar: UIImage?
@Published var name: String?
@Published var avatarImageURL: URL?
@Published var avatarImage: UIImage?
@Published var note: String?
// output
@Published var avatarImageResource = ImageResource(url: nil, image: nil)
struct ImageResource {
let url: URL?
let image: UIImage?
}
init() {
Publishers.CombineLatest(
$avatarImageURL,
$avatarImage
)
.map { url, image in
ImageResource(url: url, image: image)
}
.assign(to: &$avatarImageResource)
}
}
}
@ -103,15 +90,14 @@ extension ProfileHeaderViewModel {
}
// MARK: - ProfileViewModelEditable
extension ProfileHeaderViewModel: ProfileViewModelEditable {
func isEdited() -> Bool {
var isEdited: Bool {
guard isEditing else { return false }
guard editProfileInfo.name == displayProfileInfo.name else { return true }
guard editProfileInfo.avatarImage == nil else { return true }
guard editProfileInfo.note == ProfileHeaderViewModel.normalize(note: displayProfileInfo.note) else { return true }
guard profileInfoEditing.avatar == nil else { return true }
guard profileInfo.name == profileInfoEditing.name else { return true }
guard profileInfo.note == profileInfoEditing.note else { return true }
return false
}

View File

@ -0,0 +1,56 @@
//
// ProfileHeaderView+Configuration.swift
// Mastodon
//
// Created by MainasuK on 2022-5-26.
//
import os.log
import UIKit
import Combine
import CoreDataStack
extension ProfileHeaderView {
func configuration(user: MastodonUser) {
// header
user.publisher(for: \.header)
.map { _ in user.headerImageURL() }
.assign(to: \.headerImageURL, on: viewModel)
.store(in: &disposeBag)
// avatar
user.publisher(for: \.avatar)
.map { _ in user.avatarImageURL() }
.assign(to: \.avatarImageURL, on: viewModel)
.store(in: &disposeBag)
// emojiMeta
user.publisher(for: \.emojis)
.map { $0.asDictionary }
.assign(to: \.emojiMeta, on: viewModel)
.store(in: &disposeBag)
// name
user.publisher(for: \.displayName)
.map { _ in user.displayNameWithFallback }
.assign(to: \.name, on: viewModel)
.store(in: &disposeBag)
// username
viewModel.username = user.username
// bio
user.publisher(for: \.note)
.assign(to: \.note, on: viewModel)
.store(in: &disposeBag)
// dashboard
user.publisher(for: \.statusesCount)
.map { Int($0) }
.assign(to: \.statusesCount, on: viewModel)
.store(in: &disposeBag)
user.publisher(for: \.followingCount)
.map { Int($0) }
.assign(to: \.followingCount, on: viewModel)
.store(in: &disposeBag)
user.publisher(for: \.followersCount)
.map { Int($0) }
.assign(to: \.followersCount, on: viewModel)
.store(in: &disposeBag)
}
}

View File

@ -0,0 +1,280 @@
//
// ProfileHeaderView+ViewModel.swift
// Mastodon
//
// Created by MainasuK on 2022-5-26.
//
import os.log
import UIKit
import Combine
import CoreDataStack
import MetaTextKit
import MastodonMeta
import MastodonUI
import MastodonAsset
import MastodonLocalization
extension ProfileHeaderView {
class ViewModel: ObservableObject {
var disposeBag = Set<AnyCancellable>()
let viewDidAppear = PassthroughSubject<Void, Never>()
@Published var state: State?
@Published var isEditing = false
@Published var isUpdating = false
@Published var emojiMeta: MastodonContent.Emojis = [:]
@Published var headerImageURL: URL?
@Published var avatarImageURL: URL?
@Published var avatarImageEditing: UIImage?
@Published var name: String?
@Published var nameEditing: String?
@Published var username: String?
@Published var note: String?
@Published var noteEditing: String?
@Published var statusesCount: Int?
@Published var followingCount: Int?
@Published var followersCount: Int?
@Published var fields: [MastodonField] = []
@Published var relationshipActionOptionSet: RelationshipActionOptionSet = .none
@Published var isRelationshipActionButtonHidden = false
init() {
$relationshipActionOptionSet
.compactMap { $0.highPriorityAction(except: []) }
.map { $0 == .none }
.assign(to: &$isRelationshipActionButtonHidden)
}
}
}
extension ProfileHeaderView.ViewModel {
func bind(view: ProfileHeaderView) {
// header
Publishers.CombineLatest(
$headerImageURL,
viewDidAppear
)
.sink { headerImageURL, _ in
view.bannerImageView.af.cancelImageRequest()
let placeholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor)
guard let bannerImageURL = headerImageURL else {
view.bannerImageView.image = placeholder
return
}
view.bannerImageView.af.setImage(
withURL: bannerImageURL,
placeholderImage: placeholder,
imageTransition: .crossDissolve(0.3),
runImageTransitionIfCached: false,
completion: { [weak view] response in
guard let view = view else { return }
guard let image = response.value else { return }
guard image.size.width > 1 && image.size.height > 1 else {
// restore to placeholder when image invalid
view.bannerImageView.image = placeholder
return
}
}
)
}
.store(in: &disposeBag)
// avatar
Publishers.CombineLatest4(
$avatarImageURL,
$avatarImageEditing,
$isEditing,
viewDidAppear
)
.sink { avatarImageURL, avatarImageEditing, isEditing, _ in
view.avatarButton.avatarImageView.configure(configuration: .init(
url: (!isEditing || avatarImageEditing == nil) ? avatarImageURL : nil,
placeholder: isEditing ? (avatarImageEditing ?? AvatarImageView.placeholder) : AvatarImageView.placeholder
))
}
.store(in: &disposeBag)
// blur
$relationshipActionOptionSet
.map { $0.contains(.blocking) || $0.contains(.blockingBy) }
.sink { needsImageOverlayBlurred in
UIView.animate(withDuration: 0.33) {
let bannerEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.bannerImageViewOverlayBlurEffect : nil
view.bannerImageViewOverlayVisualEffectView.effect = bannerEffect
let avatarEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.avatarImageViewOverlayBlurEffect : nil
view.avatarImageViewOverlayVisualEffectView.effect = avatarEffect
}
}
.store(in: &disposeBag)
// name
Publishers.CombineLatest4(
$isEditing.removeDuplicates(),
$name.removeDuplicates(),
$nameEditing.removeDuplicates(),
$emojiMeta.removeDuplicates()
)
.sink { isEditing, name, nameEditing, emojiMeta in
do {
let mastodonContent = MastodonContent(content: name ?? " ", emojis: emojiMeta)
let metaContent = try MastodonMetaContent.convert(document: mastodonContent)
view.nameMetaText.configure(content: metaContent)
} catch {
assertionFailure()
}
view.nameTextField.text = isEditing ? nameEditing : name
}
.store(in: &disposeBag)
// username
$username
.map { username in username.flatMap { "@" + $0 } ?? " " }
.assign(to: \.text, on: view.usernameLabel)
.store(in: &disposeBag)
// bio
Publishers.CombineLatest4(
$isEditing.removeDuplicates(),
$emojiMeta.removeDuplicates(),
$note.removeDuplicates(),
$noteEditing.removeDuplicates()
)
.sink { isEditing, emojiMeta, note, noteEditing in
view.bioMetaText.textView.isEditable = isEditing
let metaContent: MetaContent = {
if isEditing {
return PlaintextMetaContent(string: noteEditing ?? "")
} else {
do {
let mastodonContent = MastodonContent(content: note ?? "", emojis: emojiMeta)
return try MastodonMetaContent.convert(document: mastodonContent)
} catch {
assertionFailure()
return PlaintextMetaContent(string: note ?? "")
}
}
}()
guard metaContent.string != view.bioMetaText.textStorage.string else { return }
view.bioMetaText.configure(content: metaContent)
}
.store(in: &disposeBag)
$relationshipActionOptionSet
.sink { optionSet in
let isBlocking = optionSet.contains(.blocking)
let isBlockedBy = optionSet.contains(.blockingBy)
let isSuspended = optionSet.contains(.suspended)
let isNeedsHidden = isBlocking || isBlockedBy || isSuspended
view.bioMetaText.textView.isHidden = isNeedsHidden
}
.store(in: &disposeBag)
// dashboard
$statusesCount
.sink { count in
let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-"
view.statusDashboardView.postDashboardMeterView.numberLabel.text = text
view.statusDashboardView.postDashboardMeterView.isAccessibilityElement = true
view.statusDashboardView.postDashboardMeterView.accessibilityLabel = L10n.Plural.Count.post(count ?? 0)
}
.store(in: &disposeBag)
$followingCount
.sink { count in
let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-"
view.statusDashboardView.followingDashboardMeterView.numberLabel.text = text
view.statusDashboardView.followingDashboardMeterView.isAccessibilityElement = true
view.statusDashboardView.followingDashboardMeterView.accessibilityLabel = L10n.Plural.Count.following(count ?? 0)
}
.store(in: &disposeBag)
$followersCount
.sink { count in
let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-"
view.statusDashboardView.followersDashboardMeterView.numberLabel.text = text
view.statusDashboardView.followersDashboardMeterView.isAccessibilityElement = true
view.statusDashboardView.followersDashboardMeterView.accessibilityLabel = L10n.Plural.Count.follower(count ?? 0)
}
.store(in: &disposeBag)
$isEditing
.sink { isEditing in
let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
animator.addAnimations {
view.statusDashboardView.alpha = isEditing ? 0.2 : 1.0
}
animator.startAnimation()
}
.store(in: &disposeBag)
// relationship
$isRelationshipActionButtonHidden
.assign(to: \.isHidden, on: view.relationshipActionButtonShadowContainer)
.store(in: &disposeBag)
Publishers.CombineLatest3(
$relationshipActionOptionSet,
$isEditing,
$isUpdating
)
.sink { relationshipActionOptionSet, isEditing, isUpdating in
if relationshipActionOptionSet.contains(.edit) {
// check .edit state and set .editing when isEditing
view.relationshipActionButton.configure(actionOptionSet: isUpdating ? .updating : (isEditing ? .editing : .edit))
view.configure(state: isEditing ? .editing : .normal)
} else {
view.relationshipActionButton.configure(actionOptionSet: relationshipActionOptionSet)
}
}
.store(in: &disposeBag)
}
}
extension ProfileHeaderView {
enum State {
case normal
case editing
}
func configure(state: State) {
guard viewModel.state != state else { return } // avoid redundant animation
viewModel.state = state
let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
switch state {
case .normal:
nameMetaText.textView.alpha = 1
nameTextField.alpha = 0
nameTextField.isEnabled = false
bioMetaText.textView.backgroundColor = .clear
animator.addAnimations {
self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor
self.nameTextFieldBackgroundView.backgroundColor = .clear
self.editAvatarBackgroundView.alpha = 0
}
animator.addCompletion { _ in
self.editAvatarBackgroundView.isHidden = true
}
case .editing:
nameMetaText.textView.alpha = 0
nameTextField.isEnabled = true
nameTextField.alpha = 1
editAvatarBackgroundView.isHidden = false
editAvatarBackgroundView.alpha = 0
bioMetaText.textView.backgroundColor = .clear
animator.addAnimations {
self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor
self.nameTextFieldBackgroundView.backgroundColor = Asset.Scene.Profile.Banner.nameEditBackgroundGray.color
self.editAvatarBackgroundView.alpha = 1
self.bioMetaText.textView.backgroundColor = Asset.Scene.Profile.Banner.bioEditBackgroundGray.color
}
}
animator.startAnimation()
}
}

View File

@ -38,8 +38,16 @@ final class ProfileHeaderView: UIView {
weak var delegate: ProfileHeaderViewDelegate?
var disposeBag = Set<AnyCancellable>()
var state: State?
func prepareForReuse() {
disposeBag.removeAll()
}
private(set) lazy var viewModel: ViewModel = {
let viewModel = ViewModel()
viewModel.bind(view: self)
return viewModel
}()
let bannerContainerView = UIView()
let bannerImageView: UIImageView = {
let imageView = UIImageView()
@ -61,6 +69,8 @@ final class ProfileHeaderView: UIView {
overlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor
return overlayView
}()
var bannerImageViewTopLayoutConstraint: NSLayoutConstraint!
var bannerImageViewBottomLayoutConstraint: NSLayoutConstraint!
let avatarImageViewBackgroundView: UIView = {
let view = UIView()
@ -81,7 +91,7 @@ final class ProfileHeaderView: UIView {
func setupAvatarOverlayViews() {
editAvatarBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.6)
editAvatarButton.tintColor = .white
editAvatarButtonOverlayIndicatorView.tintColor = .white
}
static let avatarImageViewOverlayBlurEffect = UIBlurEffect(style: .systemUltraThinMaterialDark)
@ -101,7 +111,7 @@ final class ProfileHeaderView: UIView {
return view
}()
let editAvatarButton: HighlightDimmableButton = {
let editAvatarButtonOverlayIndicatorView: HighlightDimmableButton = {
let button = HighlightDimmableButton()
button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28)), for: .normal)
button.tintColor = .clear
@ -136,7 +146,7 @@ final class ProfileHeaderView: UIView {
let nameTextField: UITextField = {
let textField = UITextField()
textField.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: .systemFont(ofSize: 22, weight: .bold))
textField.textColor = Asset.Colors.Label.secondary.color
textField.textColor = Asset.Colors.Label.primary.color
textField.text = "Alice"
textField.autocorrectionType = .no
textField.autocapitalizationType = .none
@ -164,8 +174,8 @@ final class ProfileHeaderView: UIView {
return button
}()
let bioContainerView = UIView()
let fieldContainerStackView = UIStackView()
// let bioContainerView = UIView()
// let fieldContainerStackView = UIStackView()
let bioMetaText: MetaText = {
let metaText = MetaText()
@ -230,12 +240,19 @@ extension ProfileHeaderView {
bannerContainerView.topAnchor.constraint(equalTo: topAnchor),
bannerContainerView.leadingAnchor.constraint(equalTo: leadingAnchor),
trailingAnchor.constraint(equalTo: bannerContainerView.trailingAnchor),
readableContentGuide.widthAnchor.constraint(equalTo: bannerContainerView.heightAnchor, multiplier: 3), // set height to 1/3 of readable frame width
bannerContainerView.widthAnchor.constraint(equalTo: bannerContainerView.heightAnchor, multiplier: 3), // aspectRatio 1 : 3
])
bannerImageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
bannerImageView.frame = bannerContainerView.bounds
bannerImageView.translatesAutoresizingMaskIntoConstraints = false
bannerContainerView.addSubview(bannerImageView)
bannerImageViewTopLayoutConstraint = bannerImageView.topAnchor.constraint(equalTo: bannerContainerView.topAnchor)
bannerImageViewBottomLayoutConstraint = bannerContainerView.bottomAnchor.constraint(equalTo: bannerImageView.bottomAnchor)
NSLayoutConstraint.activate([
bannerImageViewTopLayoutConstraint,
bannerImageView.leadingAnchor.constraint(equalTo: bannerContainerView.leadingAnchor),
bannerImageView.trailingAnchor.constraint(equalTo: bannerContainerView.trailingAnchor),
bannerImageViewBottomLayoutConstraint,
])
bannerImageViewOverlayVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
bannerImageView.addSubview(bannerImageViewOverlayVisualEffectView)
@ -283,13 +300,13 @@ extension ProfileHeaderView {
editAvatarBackgroundView.bottomAnchor.constraint(equalTo: avatarButton.bottomAnchor),
])
editAvatarButton.translatesAutoresizingMaskIntoConstraints = false
editAvatarBackgroundView.addSubview(editAvatarButton)
editAvatarButtonOverlayIndicatorView.translatesAutoresizingMaskIntoConstraints = false
editAvatarBackgroundView.addSubview(editAvatarButtonOverlayIndicatorView)
NSLayoutConstraint.activate([
editAvatarButton.topAnchor.constraint(equalTo: editAvatarBackgroundView.topAnchor),
editAvatarButton.leadingAnchor.constraint(equalTo: editAvatarBackgroundView.leadingAnchor),
editAvatarButton.trailingAnchor.constraint(equalTo: editAvatarBackgroundView.trailingAnchor),
editAvatarButton.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor),
editAvatarButtonOverlayIndicatorView.topAnchor.constraint(equalTo: editAvatarBackgroundView.topAnchor),
editAvatarButtonOverlayIndicatorView.leadingAnchor.constraint(equalTo: editAvatarBackgroundView.leadingAnchor),
editAvatarButtonOverlayIndicatorView.trailingAnchor.constraint(equalTo: editAvatarBackgroundView.trailingAnchor),
editAvatarButtonOverlayIndicatorView.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor),
])
editAvatarBackgroundView.isUserInteractionEnabled = true
avatarButton.isUserInteractionEnabled = true
@ -297,6 +314,7 @@ extension ProfileHeaderView {
// container: V - [ dashboard container | author container | bio ]
let container = UIStackView()
container.axis = .vertical
container.distribution = .fill
container.spacing = 8
container.preservesSuperviewLayoutMargins = true
container.isLayoutMarginsRelativeArrangement = true
@ -310,7 +328,7 @@ extension ProfileHeaderView {
layoutMarginsGuide.trailingAnchor.constraint(equalTo: container.trailingAnchor),
container.bottomAnchor.constraint(equalTo: bottomAnchor),
])
// dashboardContainer: H - [ padding | statusDashboardView ]
let dashboardContainer = UIStackView()
dashboardContainer.axis = .horizontal
@ -364,6 +382,7 @@ extension ProfileHeaderView {
nameTextFieldBackgroundView.trailingAnchor.constraint(equalTo: nameMetaText.textView.trailingAnchor, constant: 5),
nameMetaText.textView.bottomAnchor.constraint(equalTo: nameTextFieldBackgroundView.bottomAnchor),
])
// nameMetaText.textView.setContentHuggingPriority(, for: <#T##NSLayoutConstraint.Axis#>)
nameContainerStackView.addArrangedSubview(displayNameStackView)
nameContainerStackView.addArrangedSubview(usernameLabel)
@ -438,53 +457,6 @@ extension ProfileHeaderView {
}
extension ProfileHeaderView {
enum State {
case normal
case editing
}
func configure(state: State) {
guard self.state != state else { return } // avoid redundant animation
self.state = state
let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
switch state {
case .normal:
nameMetaText.textView.alpha = 1
nameTextField.alpha = 0
nameTextField.isEnabled = false
bioMetaText.textView.backgroundColor = .clear
animator.addAnimations {
self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor
self.nameTextFieldBackgroundView.backgroundColor = .clear
self.editAvatarBackgroundView.alpha = 0
}
animator.addCompletion { _ in
self.editAvatarBackgroundView.isHidden = true
}
case .editing:
nameMetaText.textView.alpha = 0
nameTextField.isEnabled = true
nameTextField.alpha = 1
editAvatarBackgroundView.isHidden = false
editAvatarBackgroundView.alpha = 0
bioMetaText.textView.backgroundColor = .clear
animator.addAnimations {
self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor
self.nameTextFieldBackgroundView.backgroundColor = Asset.Scene.Profile.Banner.nameEditBackgroundGray.color
self.editAvatarBackgroundView.alpha = 1
self.bioMetaText.textView.backgroundColor = Asset.Scene.Profile.Banner.bioEditBackgroundGray.color
}
}
animator.startAnimation()
}
}
extension ProfileHeaderView {
@objc private func relationshipActionButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)

View File

@ -0,0 +1,217 @@
//
// ProfilePagingViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
import Combine
import XLPagerTabStrip
import TabBarPager
import MastodonAsset
protocol ProfilePagingViewControllerDelegate: AnyObject {
func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController customScrollViewContainerController: ScrollViewContainer, atIndex index: Int)
}
final class ProfilePagingViewController: ButtonBarPagerTabStripViewController, TabBarPageViewController {
weak var tabBarPageViewDelegate: TabBarPageViewDelegate?
weak var pagingDelegate: ProfilePagingViewControllerDelegate?
var disposeBag = Set<AnyCancellable>()
var viewModel: ProfilePagingViewModel!
let buttonBarShadowView = UIView()
private var buttonBarShadowAlpha: CGFloat = 0.0
// MARK: - TabBarPageViewController
var currentPage: TabBarPage? {
return viewModel.viewControllers[currentIndex]
}
var currentPageIndex: Int? {
currentIndex
}
// MARK: - ButtonBarPagerTabStripViewController
override func viewControllers(for pagerTabStripController: PagerTabStripViewController) -> [UIViewController] {
return viewModel.viewControllers
}
override func updateIndicator(for viewController: PagerTabStripViewController, fromIndex: Int, toIndex: Int, withProgressPercentage progressPercentage: CGFloat, indexWasChanged: Bool) {
super.updateIndicator(for: viewController, fromIndex: fromIndex, toIndex: toIndex, withProgressPercentage: progressPercentage, indexWasChanged: indexWasChanged)
guard indexWasChanged else { return }
let page = viewModel.viewControllers[toIndex]
tabBarPageViewDelegate?.pageViewController(self, didPresentingTabBarPage: page, at: toIndex)
}
// make key commands works
override var canBecomeFirstResponder: Bool {
return true
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ProfilePagingViewController {
override func viewDidLoad() {
// configure style before viewDidLoad
settings.style.buttonBarBackgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor
settings.style.buttonBarItemBackgroundColor = .clear
settings.style.buttonBarItemsShouldFillAvailableWidth = false // alignment from leading to trailing
settings.style.selectedBarHeight = 3
settings.style.selectedBarBackgroundColor = Asset.Colors.Label.primary.color
settings.style.buttonBarItemFont = UIFont.systemFont(ofSize: 17, weight: .semibold)
changeCurrentIndexProgressive = { [weak self] (oldCell: ButtonBarViewCell?, newCell: ButtonBarViewCell?, progressPercentage: CGFloat, changeCurrentIndex: Bool, animated: Bool) -> Void in
guard let _ = self else { return }
guard changeCurrentIndex == true else { return }
oldCell?.label.textColor = Asset.Colors.Label.secondary.color
newCell?.label.textColor = Asset.Colors.Label.primary.color
}
super.viewDidLoad()
ThemeService.shared.currentTheme
.receive(on: DispatchQueue.main)
.sink { [weak self] theme in
guard let self = self else { return }
self.settings.style.buttonBarBackgroundColor = theme.systemBackgroundColor
self.barButtonLayout?.invalidateLayout()
}
.store(in: &disposeBag)
updateBarButtonInsets()
if let buttonBarView = self.buttonBarView {
buttonBarShadowView.translatesAutoresizingMaskIntoConstraints = false
view.insertSubview(buttonBarShadowView, belowSubview: buttonBarView)
NSLayoutConstraint.activate([
buttonBarShadowView.topAnchor.constraint(equalTo: buttonBarView.topAnchor),
buttonBarShadowView.leadingAnchor.constraint(equalTo: buttonBarView.leadingAnchor),
buttonBarShadowView.trailingAnchor.constraint(equalTo: buttonBarView.trailingAnchor),
buttonBarShadowView.bottomAnchor.constraint(equalTo: buttonBarView.bottomAnchor),
])
viewModel.$needsSetupBottomShadow
.receive(on: DispatchQueue.main)
.sink { [weak self] needsSetupBottomShadow in
guard let self = self else { return }
self.setupBottomShadow()
}
.store(in: &disposeBag)
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
becomeFirstResponder()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
setupBottomShadow()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
updateBarButtonInsets()
}
}
extension ProfilePagingViewController {
private func updateBarButtonInsets() {
let margin: CGFloat = {
switch traitCollection.userInterfaceIdiom {
case .phone:
return ProfileViewController.containerViewMarginForCompactHorizontalSizeClass
default:
return traitCollection.horizontalSizeClass == .regular ?
ProfileViewController.containerViewMarginForRegularHorizontalSizeClass :
ProfileViewController.containerViewMarginForCompactHorizontalSizeClass
}
}()
settings.style.buttonBarLeftContentInset = margin
settings.style.buttonBarRightContentInset = margin
barButtonLayout?.sectionInset.left = margin
barButtonLayout?.sectionInset.right = margin
barButtonLayout?.invalidateLayout()
}
private var barButtonLayout: UICollectionViewFlowLayout? {
let layout = buttonBarView.collectionViewLayout as? UICollectionViewFlowLayout
return layout
}
func setupBottomShadow() {
guard viewModel.needsSetupBottomShadow else {
buttonBarShadowView.layer.shadowColor = nil
buttonBarShadowView.layer.shadowRadius = 0
return
}
buttonBarShadowView.layer.setupShadow(
color: UIColor.black.withAlphaComponent(0.12),
alpha: Float(buttonBarShadowAlpha),
x: 0,
y: 2,
blur: 2,
spread: 0,
roundedRect: buttonBarShadowView.bounds,
byRoundingCorners: .allCorners,
cornerRadii: .zero
)
}
func updateButtonBarShadow(progress: CGFloat) {
let alpha = min(max(0, 10 * progress - 9), 1)
if buttonBarShadowAlpha != alpha {
buttonBarShadowAlpha = alpha
setupBottomShadow()
buttonBarShadowView.setNeedsLayout()
}
}
}
extension ProfilePagingViewController {
var currentViewController: (UIViewController & TabBarPage)? {
guard !viewModel.viewControllers.isEmpty,
currentIndex < viewModel.viewControllers.count
else { return nil }
return viewModel.viewControllers[currentIndex]
}
}
// workaround to fix tab man responder chain issue
extension ProfilePagingViewController {
override var keyCommands: [UIKeyCommand]? {
return currentViewController?.keyCommands
}
@objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
(currentViewController as? StatusTableViewControllerNavigateable)?.navigateKeyCommandHandlerRelay(sender)
}
@objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
(currentViewController as? StatusTableViewControllerNavigateable)?.statusKeyCommandHandlerRelay(sender)
}
}

View File

@ -18,6 +18,9 @@ final class ProfilePagingViewModel: NSObject {
let mediaUserTimelineViewController = UserTimelineViewController()
let profileAboutViewController = ProfileAboutViewController()
// input
@Published var needsSetupBottomShadow = true
init(
postsUserTimelineViewModel: UserTimelineViewModel,
repliesUserTimelineViewModel: UserTimelineViewModel,
@ -40,42 +43,8 @@ final class ProfilePagingViewModel: NSObject {
]
}
// let barItems: [TMBarItemable] = {
// let items = [
// TMBarItem(title: L10n.Scene.Profile.SegmentedControl.posts),
// TMBarItem(title: L10n.Scene.Profile.SegmentedControl.postsAndReplies),
// TMBarItem(title: L10n.Scene.Profile.SegmentedControl.media),
// TMBarItem(title: L10n.Scene.Profile.SegmentedControl.about),
// ]
// return items
// }()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
//// MARK: - PageboyViewControllerDataSource
//extension ProfilePagingViewModel: PageboyViewControllerDataSource {
//
// func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int {
// return viewControllers.count
// }
//
// func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? {
// return viewControllers[index]
// }
//
// func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? {
// return .first
// }
//
//}
//
//// MARK: - TMBarDataSource
//extension ProfilePagingViewModel: TMBarDataSource {
// func barItem(for bar: TMBar, at index: Int) -> TMBarItemable {
// return barItems[index]
// }
//}

File diff suppressed because it is too large Load Diff

View File

@ -41,74 +41,38 @@ class ProfileViewModel: NSObject {
@Published var isEditing = false
@Published var isUpdating = false
@Published var accountForEdit: Mastodon.Entity.Account?
// output
let relationshipViewModel = RelationshipViewModel()
@Published var userIdentifier: UserIdentifier? = nil
@Published var isRelationshipActionButtonHidden: Bool = true
@Published var isReplyBarButtonItemHidden: Bool = true
@Published var isMoreMenuBarButtonItemHidden: Bool = true
@Published var isMeBarButtonItemsHidden: Bool = true
@Published var isPagingEnabled = true
// let domain: CurrentValueSubject<String?, Never>
// let userID: CurrentValueSubject<UserID?, Never>
// let bannerImageURL: CurrentValueSubject<URL?, Never>
// let avatarImageURL: CurrentValueSubject<URL?, Never>
// let name: CurrentValueSubject<String?, Never>
// let username: CurrentValueSubject<String?, Never>
// let bioDescription: CurrentValueSubject<String?, Never>
// let url: CurrentValueSubject<String?, Never>
// let statusesCount: CurrentValueSubject<Int?, Never>
// let followingCount: CurrentValueSubject<Int?, Never>
// let followersCount: CurrentValueSubject<Int?, Never>
// let fields: CurrentValueSubject<[MastodonField], Never>
// let emojiMeta: CurrentValueSubject<MastodonContent.Emojis, Never>
// fulfill this before editing
let accountForEdit = CurrentValueSubject<Mastodon.Entity.Account?, Never>(nil)
// let protected: CurrentValueSubject<Bool?, Never>
// let suspended: CurrentValueSubject<Bool, Never>
//
// let relationshipActionOptionSet = CurrentValueSubject<RelationshipActionOptionSet, Never>(.none)
// let isFollowedBy = CurrentValueSubject<Bool, Never>(false)
// let isMuting = CurrentValueSubject<Bool, Never>(false)
// let isBlocking = CurrentValueSubject<Bool, Never>(false)
// let isBlockedBy = CurrentValueSubject<Bool, Never>(false)
//
// let isRelationshipActionButtonHidden = CurrentValueSubject<Bool, Never>(true)
// let isReplyBarButtonItemHidden = CurrentValueSubject<Bool, Never>(true)
// let isMoreMenuBarButtonItemHidden = CurrentValueSubject<Bool, Never>(true)
// let isMeBarButtonItemsHidden = CurrentValueSubject<Bool, Never>(true)
//
// let needsPagePinToTop = CurrentValueSubject<Bool, Never>(false)
// let needsPagingEnabled = CurrentValueSubject<Bool, Never>(true)
// let needsImageOverlayBlurred = CurrentValueSubject<Bool, Never>(false)
// @Published var protected: Bool? = nil
// let needsPagePinToTop = CurrentValueSubject<Bool, Never>(false)
init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) {
self.context = context
self.user = mastodonUser
// self.domain = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value?.domain)
// self.userID = CurrentValueSubject(mastodonUser?.id)
// self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL())
// self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL())
// self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback)
// self.username = CurrentValueSubject(mastodonUser?.acctWithDomain)
// self.bioDescription = CurrentValueSubject(mastodonUser?.note)
// self.url = CurrentValueSubject(mastodonUser?.url)
// self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.statusesCount) })
// self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.followingCount) })
// self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.followersCount) })
// self.protected = CurrentValueSubject(mastodonUser?.locked)
// self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false)
// self.fields = CurrentValueSubject(mastodonUser?.fields ?? [])
// self.emojiMeta = CurrentValueSubject(mastodonUser?.emojis.asDictionary ?? [:])
self.postsUserTimelineViewModel = UserTimelineViewModel(
context: context,
title: L10n.Scene.Profile.SegmentedControl.posts,
queryFilter: .init(excludeReplies: true)
)
self.repliesUserTimelineViewModel = UserTimelineViewModel(
context: context,
title: L10n.Scene.Profile.SegmentedControl.postsAndReplies,
queryFilter: .init(excludeReplies: true)
)
self.mediaUserTimelineViewModel = UserTimelineViewModel(
context: context,
title: L10n.Scene.Profile.SegmentedControl.media,
queryFilter: .init(onlyMedia: true)
)
self.profileAboutViewModel = ProfileAboutViewModel(context: context)
@ -122,6 +86,9 @@ class ProfileViewModel: NSObject {
self.me = authenticationBox?.authenticationRecord.object(in: context.managedObjectContext)?.user
}
.store(in: &disposeBag)
$me
.assign(to: \.me, on: relationshipViewModel)
.store(in: &disposeBag)
// bind user
$user
@ -130,250 +97,88 @@ class ProfileViewModel: NSObject {
return MastodonUserIdentifier(domain: user.domain, userID: user.id)
}
.assign(to: &$userIdentifier)
$user
.assign(to: \.user, on: relationshipViewModel)
.store(in: &disposeBag)
// bind userIdentifier
$userIdentifier.assign(to: &postsUserTimelineViewModel.$userIdentifier)
$userIdentifier.assign(to: &repliesUserTimelineViewModel.$userIdentifier)
$userIdentifier.assign(to: &mediaUserTimelineViewModel.$userIdentifier)
// $userIdentifier.assign(to: &profileAboutViewModel.$userIdentifier)
// relationshipActionOptionSet
// .compactMap { $0.highPriorityAction(except: []) }
// .map { $0 == .none }
// .assign(to: \.value, on: isRelationshipActionButtonHidden)
// .store(in: &disposeBag)
//
// bind bar button items
relationshipViewModel.$optionSet
.sink { [weak self] optionSet in
guard let self = self else { return }
guard let optionSet = optionSet, !optionSet.contains(.none) else {
self.isReplyBarButtonItemHidden = true
self.isMoreMenuBarButtonItemHidden = true
self.isMeBarButtonItemsHidden = true
return
}
let isMyself = optionSet.contains(.isMyself)
self.isReplyBarButtonItemHidden = isMyself
self.isMoreMenuBarButtonItemHidden = isMyself
self.isMeBarButtonItemsHidden = !isMyself
}
.store(in: &disposeBag)
//
// // query relationship
// let userRecord = $user.map { user -> ManagedObjectRecord<MastodonUser>? in
// user.flatMap { ManagedObjectRecord<MastodonUser>(objectID: $0.objectID) }
// }
// let pendingRetryPublisher = CurrentValueSubject<TimeInterval, Never>(1)
//
// // observe friendship
// Publishers.CombineLatest3(
// userRecord,
// context.authenticationService.activeMastodonAuthenticationBox,
// pendingRetryPublisher
// )
// .sink { [weak self] userRecord, authenticationBox, _ in
// guard let self = self else { return }
// guard let userRecord = userRecord,
// let authenticationBox = authenticationBox
// else { return }
// Task {
// do {
// let response = try await self.updateRelationship(
// record: userRecord,
// authenticationBox: 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)
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch again due to pending", ((#file as NSString).lastPathComponent), #line, #function)
// }
// }
// } catch {
// self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Relationship] update user relationship failure: \(error.localizedDescription)")
// }
// }
// }
// .store(in: &disposeBag)
//
// let isBlockingOrBlocked = Publishers.CombineLatest(
// isBlocking,
// isBlockedBy
// )
// .map { $0 || $1 }
// .share()
//
// isBlockingOrBlocked
// .map { !$0 }
// .assign(to: \.value, on: needsPagingEnabled)
// .store(in: &disposeBag)
//
// isBlockingOrBlocked
// .map { $0 }
// .assign(to: \.value, on: needsImageOverlayBlurred)
// .store(in: &disposeBag)
//
// setup()
}
}
// query relationship
let userRecord = $user.map { user -> ManagedObjectRecord<MastodonUser>? in
user.flatMap { ManagedObjectRecord<MastodonUser>(objectID: $0.objectID) }
}
let pendingRetryPublisher = CurrentValueSubject<TimeInterval, Never>(1)
extension ProfileViewModel {
private func setup() {
Publishers.CombineLatest(
$user,
$me
// observe friendship
Publishers.CombineLatest3(
userRecord,
context.authenticationService.activeMastodonAuthenticationBox,
pendingRetryPublisher
)
.receive(on: DispatchQueue.main)
.sink { [weak self] user, me in
.sink { [weak self] userRecord, authenticationBox, _ in
guard let self = self else { return }
// Update view model attribute
self.update(mastodonUser: user)
self.update(mastodonUser: user, currentMastodonUser: me)
// Setup observer for user
if let mastodonUser = user {
// setup observer
self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser)
.sink { completion in
switch completion {
case .failure(let error):
assertionFailure(error.localizedDescription)
case .finished:
assertionFailure()
}
} receiveValue: { [weak self] change in
guard let self = self else { return }
guard let changeType = change.changeType else { return }
switch changeType {
case .update:
self.update(mastodonUser: mastodonUser)
self.update(mastodonUser: mastodonUser, currentMastodonUser: me)
case .delete:
// TODO:
break
guard let userRecord = userRecord,
let authenticationBox = authenticationBox
else { return }
Task {
do {
let response = try await self.updateRelationship(
record: userRecord,
authenticationBox: 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)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch again due to pending", ((#file as NSString).lastPathComponent), #line, #function)
}
}
} else {
self.mastodonUserObserver = nil
}
// Setup observer for user
if let currentMastodonUser = me {
// setup observer
self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser)
.sink { completion in
switch completion {
case .failure(let error):
assertionFailure(error.localizedDescription)
case .finished:
assertionFailure()
}
} receiveValue: { [weak self] change in
guard let self = self else { return }
guard let changeType = change.changeType else { return }
switch changeType {
case .update:
self.update(mastodonUser: user, currentMastodonUser: currentMastodonUser)
case .delete:
// TODO:
break
}
}
} else {
self.currentMastodonUserObserver = nil
}
} catch {
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Relationship] update user relationship failure: \(error.localizedDescription)")
}
} // end Task
}
.store(in: &disposeBag)
//
let isBlockingOrBlocked = Publishers.CombineLatest(
relationshipViewModel.$isBlocking,
relationshipViewModel.$isBlockingBy
)
.map { $0 || $1 }
.share()
Publishers.CombineLatest(
isBlockingOrBlocked,
$isEditing
)
.map { !$0 && !$1 }
.assign(to: &$isPagingEnabled)
}
private func update(mastodonUser: MastodonUser?) {
// self.userID.value = mastodonUser?.id
// self.bannerImageURL.value = mastodonUser?.headerImageURL()
// self.avatarImageURL.value = mastodonUser?.avatarImageURL()
// self.name.value = mastodonUser?.displayNameWithFallback
// self.username.value = mastodonUser?.acctWithDomain
// self.bioDescription.value = mastodonUser?.note
// self.url.value = mastodonUser?.url
// self.statusesCount.value = mastodonUser.flatMap { Int($0.statusesCount) }
// self.followingCount.value = mastodonUser.flatMap { Int($0.followingCount) }
// self.followersCount.value = mastodonUser.flatMap { Int($0.followersCount) }
// self.protected.value = mastodonUser?.locked
// self.suspended.value = mastodonUser?.suspended ?? false
// self.fields.value = mastodonUser?.fields ?? []
// self.emojiMeta.value = mastodonUser?.emojis.asDictionary ?? [:]
}
private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) {
// guard let mastodonUser = mastodonUser,
// let currentMastodonUser = currentMastodonUser else {
// // set relationship
// self.relationshipActionOptionSet.value = .none
// self.isFollowedBy.value = false
// self.isMuting.value = false
// self.isBlocking.value = false
// self.isBlockedBy.value = false
//
// // set bar button item state
// self.isReplyBarButtonItemHidden.value = true
// self.isMoreMenuBarButtonItemHidden.value = true
// self.isMeBarButtonItemsHidden.value = true
// return
// }
//
// if mastodonUser == currentMastodonUser {
// self.relationshipActionOptionSet.value = [.edit]
// // set bar button item state
// self.isReplyBarButtonItemHidden.value = true
// self.isMoreMenuBarButtonItemHidden.value = true
// self.isMeBarButtonItemsHidden.value = false
// } else {
// // set with follow action default
// var relationshipActionSet = RelationshipActionOptionSet([.follow])
//
// if mastodonUser.locked {
// relationshipActionSet.insert(.request)
// }
//
// if mastodonUser.suspended {
// relationshipActionSet.insert(.suspended)
// }
//
// let isFollowing = mastodonUser.followingBy.contains(currentMastodonUser)
// if isFollowing {
// relationshipActionSet.insert(.following)
// }
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowing: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowing.description)
//
// let isPending = mastodonUser.followRequestedBy.contains(currentMastodonUser)
// if isPending {
// relationshipActionSet.insert(.pending)
// }
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isPending: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isPending.description)
//
// let isFollowedBy = currentMastodonUser.followingBy.contains(mastodonUser)
// self.isFollowedBy.value = isFollowedBy
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowedBy.description)
//
// let isMuting = mastodonUser.mutingBy.contains(currentMastodonUser)
// if isMuting {
// relationshipActionSet.insert(.muting)
// }
// self.isMuting.value = isMuting
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isMuting: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isMuting.description)
//
// let isBlocking = mastodonUser.blockingBy.contains(currentMastodonUser)
// if isBlocking {
// relationshipActionSet.insert(.blocking)
// }
// self.isBlocking.value = isBlocking
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlocking: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlocking.description)
//
// let isBlockedBy = currentMastodonUser.blockingBy.contains(mastodonUser)
// if isBlockedBy {
// relationshipActionSet.insert(.blocked)
// }
// self.isBlockedBy.value = isBlockedBy
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlockedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlockedBy.description)
//
// self.relationshipActionOptionSet.value = relationshipActionSet
//
// // set bar button item state
// self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy
// self.isMoreMenuBarButtonItemHidden.value = false
// self.isMeBarButtonItemsHidden.value = true
// }
}
}
extension ProfileViewModel {
@ -418,7 +223,7 @@ extension ProfileViewModel {
let authorization = authenticationBox.userAuthorization
let _image: UIImage? = {
guard let image = headerProfileInfo.avatarImage else { return nil }
guard let image = headerProfileInfo.avatar else { return nil }
guard image.size.width <= ProfileHeaderViewModel.avatarImageMaxSizeInPixel.width else {
return image.af.imageScaled(to: ProfileHeaderViewModel.avatarImageMaxSizeInPixel)
}

View File

@ -1,92 +0,0 @@
//
// ProfilePagingViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
import XLPagerTabStrip
import TabBarPager
protocol ProfilePagingViewControllerDelegate: AnyObject {
func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController customScrollViewContainerController: ScrollViewContainer, atIndex index: Int)
}
final class ProfilePagingViewController: ButtonBarPagerTabStripViewController, TabBarPageViewController {
weak var tabBarPageViewDelegate: TabBarPageViewDelegate?
weak var pagingDelegate: ProfilePagingViewControllerDelegate?
var viewModel: ProfilePagingViewModel!
// MARK: - TabBarPageViewController
var currentPage: TabBarPage? {
return viewModel.viewControllers[currentIndex]
}
var currentPageIndex: Int? {
currentIndex
}
// MARK: - ButtonBarPagerTabStripViewController
override func viewControllers(for pagerTabStripController: PagerTabStripViewController) -> [UIViewController] {
return viewModel.viewControllers
}
override func updateIndicator(for viewController: PagerTabStripViewController, fromIndex: Int, toIndex: Int, withProgressPercentage progressPercentage: CGFloat, indexWasChanged: Bool) {
super.updateIndicator(for: viewController, fromIndex: fromIndex, toIndex: toIndex, withProgressPercentage: progressPercentage, indexWasChanged: indexWasChanged)
guard indexWasChanged else { return }
let page = viewModel.viewControllers[toIndex]
tabBarPageViewDelegate?.pageViewController(self, didPresentingTabBarPage: page, at: toIndex)
}
// make key commands works
override var canBecomeFirstResponder: Bool {
return true
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ProfilePagingViewController {
override func viewDidLoad() {
super.viewDidLoad()
// view.backgroundColor = .clear
// dataSource = viewModel
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
becomeFirstResponder()
}
}
// workaround to fix tab man responder chain issue
extension ProfilePagingViewController {
// override var keyCommands: [UIKeyCommand]? {
// return currentPage?.keyCommands
// }
//
// @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
// (currentViewController as? StatusTableViewControllerNavigateable)?.navigateKeyCommandHandlerRelay(sender)
//
// }
//
// @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
// (currentViewController as? StatusTableViewControllerNavigateable)?.statusKeyCommandHandlerRelay(sender)
// }
}

View File

@ -1,39 +0,0 @@
//
// ProfileSegmentedViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-29.
//
import os.log
import UIKit
final class ProfileSegmentedViewController: UIViewController {
let pagingViewController = ProfilePagingViewController()
deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ProfileSegmentedViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
addChild(pagingViewController)
pagingViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pagingViewController.view)
pagingViewController.didMove(toParent: self)
NSLayoutConstraint.activate([
pagingViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
pagingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: pagingViewController.view.trailingAnchor),
view.bottomAnchor.constraint(equalTo: pagingViewController.view.bottomAnchor),
])
}
}

View File

@ -178,6 +178,6 @@ extension UserTimelineViewController: StatusTableViewControllerNavigateable {
// MARK: - IndicatorInfoProvider
extension UserTimelineViewController: IndicatorInfoProvider {
func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo {
return IndicatorInfo(title: "Hello")
return IndicatorInfo(title: viewModel.title)
}
}

View File

@ -40,9 +40,9 @@ extension UserTimelineViewModel {
.store(in: &disposeBag)
let needsTimelineHidden = Publishers.CombineLatest3(
isBlocking,
isBlockedBy,
isSuspended
$isBlocking,
$isBlockedBy,
$isSuspended
).map { $0 || $1 || $2 }
Publishers.CombineLatest(

View File

@ -194,7 +194,7 @@ extension UserTimelineViewModel.State {
guard let viewModel = viewModel, let _ = stateMachine else { return }
// trigger data source update. otherwise, spinner always display
viewModel.isSuspended.value = viewModel.isSuspended.value
viewModel.isSuspended = viewModel.isSuspended
// remove bottom loader
guard let diffableDataSource = viewModel.diffableDataSource else { return }

View File

@ -19,16 +19,18 @@ final class UserTimelineViewModel {
// input
let context: AppContext
@Published var userIdentifier: UserIdentifier?
@Published var queryFilter: QueryFilter
let title: String
let statusFetchedResultsController: StatusFetchedResultsController
let listBatchFetchViewModel = ListBatchFetchViewModel()
@Published var userIdentifier: UserIdentifier?
@Published var queryFilter: QueryFilter
let isBlocking = CurrentValueSubject<Bool, Never>(false)
let isBlockedBy = CurrentValueSubject<Bool, Never>(false)
let isSuspended = CurrentValueSubject<Bool, Never>(false)
let userDisplayName = CurrentValueSubject<String?, Never>(nil) // for suspended prompt label
var dataSourceDidUpdate = PassthroughSubject<Void, Never>()
@Published var isBlocking = false
@Published var isBlockedBy = false
@Published var isSuspended = false
// let userDisplayName = CurrentValueSubject<String?, Never>(nil) // for suspended prompt label
// var dataSourceDidUpdate = PassthroughSubject<Void, Never>()
// output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>?
@ -47,9 +49,11 @@ final class UserTimelineViewModel {
init(
context: AppContext,
title: String,
queryFilter: QueryFilter
) {
self.context = context
self.title = title
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: nil,

View File

@ -7,12 +7,12 @@
import UIKit
// Make status bar style adaptive for child view controller
// SeeAlso: `modalPresentationCapturesStatusBarAppearance`
class AdaptiveStatusBarStyleNavigationController: UINavigationController {
private lazy var fullWidthBackGestureRecognizer = UIPanGestureRecognizer()
// Make status bar style adaptive for child view controller
// SeeAlso: `modalPresentationCapturesStatusBarAppearance`
override var childForStatusBarStyle: UIViewController? {
visibleViewController
}

View File

@ -84,7 +84,7 @@ public struct RelationshipActionOptionSet: OptionSet {
case .pending: return L10n.Common.Controls.Friendship.pending
case .following: return L10n.Common.Controls.Friendship.following
case .muting: return L10n.Common.Controls.Friendship.muted
case .blocked: return L10n.Common.Controls.Friendship.follow // blocked by user
case .blocked: return L10n.Common.Controls.Friendship.follow // blocked by user (deprecated)
case .blocking: return L10n.Common.Controls.Friendship.blocked
case .suspended: return L10n.Common.Controls.Friendship.follow
case .edit: return L10n.Common.Controls.Friendship.editInfo
@ -116,6 +116,7 @@ public final class RelationshipViewModel {
@Published public var isMuting = false
@Published public var isBlocking = false
@Published public var isBlockingBy = false
@Published public var isSuspended = false
public init() {
Publishers.CombineLatest3(
@ -182,8 +183,8 @@ extension RelationshipViewModel {
self.isMuting = optionSet.contains(.muting)
self.isBlockingBy = optionSet.contains(.blockingBy)
self.isBlocking = optionSet.contains(.blocking)
self.isSuspended = optionSet.contains(.suspended)
self.optionSet = optionSet
}
@ -203,7 +204,7 @@ extension RelationshipViewModel {
public static func optionSet(user: MastodonUser, me: MastodonUser) -> RelationshipActionOptionSet {
let isMyself = user.id == me.id && user.domain == me.domain
guard !isMyself else {
return [.isMyself]
return [.isMyself, .edit]
}
let isProtected = user.locked
@ -247,6 +248,10 @@ extension RelationshipViewModel {
if isBlocking {
optionSet.insert(.blocking)
}
if user.suspended {
optionSet.insert(.suspended)
}
return optionSet
}