feat: handle suspended account in profile scene

This commit is contained in:
CMK 2021-04-08 16:53:32 +08:00
parent ba48adb470
commit 14176be4ed
17 changed files with 205 additions and 49 deletions

View File

@ -82,6 +82,7 @@
<attribute name="locked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="locked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="note" optional="YES" attributeType="String"/> <attribute name="note" optional="YES" attributeType="String"/>
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="suspended" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" optional="YES" attributeType="String"/> <attribute name="url" optional="YES" attributeType="String"/>
<attribute name="username" attributeType="String"/> <attribute name="username" attributeType="String"/>
@ -199,7 +200,7 @@
<element name="History" positionX="27" positionY="126" width="128" height="119"/> <element name="History" positionX="27" positionY="126" width="128" height="119"/>
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/> <element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
<element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/> <element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="659"/> <element name="MastodonUser" positionX="0" positionY="0" width="128" height="674"/>
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/> <element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
<element name="Poll" positionX="72" positionY="162" width="128" height="194"/> <element name="Poll" positionX="72" positionY="162" width="128" height="194"/>
<element name="PollOption" positionX="81" positionY="171" width="128" height="134"/> <element name="PollOption" positionX="81" positionY="171" width="128" height="134"/>

View File

@ -31,6 +31,7 @@ final public class MastodonUser: NSManagedObject {
@NSManaged public private(set) var locked: Bool @NSManaged public private(set) var locked: Bool
@NSManaged public private(set) var bot: Bool @NSManaged public private(set) var bot: Bool
@NSManaged public private(set) var suspended: Bool
@NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var updatedAt: Date
@ -93,6 +94,7 @@ extension MastodonUser {
user.locked = property.locked user.locked = property.locked
user.bot = property.bot ?? false user.bot = property.bot ?? false
user.suspended = property.suspended ?? false
// Mastodon do not provide relationship on the `Account` // Mastodon do not provide relationship on the `Account`
// Update relationship via attribute updating interface // Update relationship via attribute updating interface
@ -174,6 +176,11 @@ extension MastodonUser {
self.bot = bot self.bot = bot
} }
} }
public func update(suspended: Bool) {
if self.suspended != suspended {
self.suspended = suspended
}
}
public func update(isFollowing: Bool, by mastodonUser: MastodonUser) { public func update(isFollowing: Bool, by mastodonUser: MastodonUser) {
if isFollowing { if isFollowing {
@ -268,6 +275,7 @@ extension MastodonUser {
public let followersCount: Int public let followersCount: Int
public let locked: Bool public let locked: Bool
public let bot: Bool? public let bot: Bool?
public let suspended: Bool?
public let createdAt: Date public let createdAt: Date
public let networkDate: Date public let networkDate: Date
@ -289,6 +297,7 @@ extension MastodonUser {
followersCount: Int, followersCount: Int,
locked: Bool, locked: Bool,
bot: Bool?, bot: Bool?,
suspended: Bool?,
createdAt: Date, createdAt: Date,
networkDate: Date networkDate: Date
) { ) {
@ -309,6 +318,7 @@ extension MastodonUser {
self.followersCount = followersCount self.followersCount = followersCount
self.locked = locked self.locked = locked
self.bot = bot self.bot = bot
self.suspended = suspended
self.createdAt = createdAt self.createdAt = createdAt
self.networkDate = networkDate self.networkDate = networkDate
} }

View File

@ -69,6 +69,7 @@
"firendship": { "firendship": {
"follow": "Follow", "follow": "Follow",
"following": "Following", "following": "Following",
"request": "Request",
"pending": "Pending", "pending": "Pending",
"block": "Block", "block": "Block",
"block_user": "Block %s", "block_user": "Block %s",
@ -91,7 +92,8 @@
"no_status_found": "No Status Found", "no_status_found": "No Status Found",
"blocking_warning": "You cant view Artbots profile\n until you unblock them.\nYour account looks like this to them.", "blocking_warning": "You cant view Artbots profile\n until you unblock them.\nYour account looks like this to them.",
"blocked_warning": "You cant view Artbots profile\n until they unblock you.", "blocked_warning": "You cant view Artbots profile\n until they unblock you.",
"suspended_warning": "This account is suspended." "suspended_warning": "This account has been suspended.",
"user_suspended_warning": "%s's account has been suspended."
} }
} }
}, },

View File

@ -757,17 +757,9 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
0F1E2D102615C39800C38565 /* View */ = {
isa = PBXGroup;
children = (
);
path = View;
sourceTree = "<group>";
};
0F2021F5261325ED000C64BF /* HashtagTimeline */ = { 0F2021F5261325ED000C64BF /* HashtagTimeline */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0F1E2D102615C39800C38565 /* View */,
0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */, 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */,
0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */, 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */,
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */,

View File

@ -63,11 +63,21 @@ extension Item {
let id = UUID() let id = UUID()
let reason: Reason let reason: Reason
enum Reason { enum Reason: Equatable {
case noStatusFound case noStatusFound
case blocking case blocking
case blocked case blocked
case suspended case suspended(name: String?)
static func == (lhs: Item.EmptyStateHeaderAttribute.Reason, rhs: Item.EmptyStateHeaderAttribute.Reason) -> Bool {
switch (lhs, rhs) {
case (.noStatusFound, noStatusFound): return true
case (.blocking, blocking): return true
case (.blocked, blocked): return true
case (.suspended(let nameLeft), .suspended(let nameRight)): return nameLeft == nameRight
default: return false
}
}
} }
init(reason: Reason) { init(reason: Reason) {

View File

@ -28,6 +28,7 @@ extension MastodonUser.Property {
followersCount: entity.followersCount, followersCount: entity.followersCount,
locked: entity.locked, locked: entity.locked,
bot: entity.bot, bot: entity.bot,
suspended: entity.suspended,
createdAt: entity.createdAt, createdAt: entity.createdAt,
networkDate: networkDate networkDate: networkDate
) )

View File

@ -112,6 +112,8 @@ internal enum L10n {
} }
/// Pending /// Pending
internal static let pending = L10n.tr("Localizable", "Common.Controls.Firendship.Pending") internal static let pending = L10n.tr("Localizable", "Common.Controls.Firendship.Pending")
/// Request
internal static let request = L10n.tr("Localizable", "Common.Controls.Firendship.Request")
/// Unblock /// Unblock
internal static let unblock = L10n.tr("Localizable", "Common.Controls.Firendship.Unblock") internal static let unblock = L10n.tr("Localizable", "Common.Controls.Firendship.Unblock")
/// Unblock %@ /// Unblock %@
@ -179,8 +181,12 @@ internal enum L10n {
internal static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning") internal static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning")
/// No Status Found /// No Status Found
internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound") internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound")
/// This account is suspended. /// This account has been suspended.
internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning") internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning")
/// %@'s account has been suspended.
internal static func userSuspendedWarning(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserSuspendedWarning", String(describing: p1))
}
} }
internal enum Loader { internal enum Loader {
/// Loading missing posts... /// Loading missing posts...

View File

@ -38,6 +38,7 @@ Please check your internet connection.";
"Common.Controls.Firendship.MuteUser" = "Mute %@"; "Common.Controls.Firendship.MuteUser" = "Mute %@";
"Common.Controls.Firendship.Muted" = "Muted"; "Common.Controls.Firendship.Muted" = "Muted";
"Common.Controls.Firendship.Pending" = "Pending"; "Common.Controls.Firendship.Pending" = "Pending";
"Common.Controls.Firendship.Request" = "Request";
"Common.Controls.Firendship.Unblock" = "Unblock"; "Common.Controls.Firendship.Unblock" = "Unblock";
"Common.Controls.Firendship.UnblockUser" = "Unblock %@"; "Common.Controls.Firendship.UnblockUser" = "Unblock %@";
"Common.Controls.Firendship.Unmute" = "Unmute"; "Common.Controls.Firendship.Unmute" = "Unmute";
@ -60,7 +61,8 @@ Please check your internet connection.";
until you unblock them. until you unblock them.
Your account looks like this to them."; Your account looks like this to them.";
"Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found"; "Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found";
"Common.Controls.Timeline.Header.SuspendedWarning" = "This account is suspended."; "Common.Controls.Timeline.Header.SuspendedWarning" = "This account has been suspended.";
"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@'s account has been suspended.";
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts"; "Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts..."; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
"Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Multiple" = "photos";

View File

@ -25,6 +25,10 @@ extension HomeTimelineViewController {
guard let self = self else { return } guard let self = self else { return }
self.showPublicTimelineAction(action) self.showPublicTimelineAction(action)
}, },
UIAction(title: "Show Profile", image: UIImage(systemName: "person.crop.circle"), attributes: []) { [weak self] action in
guard let self = self else { return }
self.showProfileAction(action)
},
UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in
guard let self = self else { return } guard let self = self else { return }
self.signOutAction(action) self.signOutAction(action)
@ -277,5 +281,20 @@ extension HomeTimelineViewController {
coordinator.present(scene: .publicTimeline, from: self, transition: .show) coordinator.present(scene: .publicTimeline, from: self, transition: .show)
} }
@objc private func showProfileAction(_ sender: UIAction) {
let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert)
alertController.addTextField()
let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in
guard let self = self else { return }
guard let textField = alertController?.textFields?.first else { return }
let profileViewModel = RemoteProfileViewModel(context: self.context, userID: textField.text ?? "")
self.coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show)
}
alertController.addAction(showAction)
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
alertController.addAction(cancelAction)
coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
}
} }
#endif #endif

View File

@ -7,6 +7,7 @@
import os.log import os.log
import UIKit import UIKit
import Combine
protocol ProfileHeaderViewControllerDelegate: class { protocol ProfileHeaderViewControllerDelegate: class {
func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView)
@ -21,6 +22,8 @@ final class ProfileHeaderViewController: UIViewController {
weak var delegate: ProfileHeaderViewControllerDelegate? weak var delegate: ProfileHeaderViewControllerDelegate?
var disposeBag = Set<AnyCancellable>()
let profileHeaderView = ProfileHeaderView() let profileHeaderView = ProfileHeaderView()
let pageSegmentedControl: UISegmentedControl = { let pageSegmentedControl: UISegmentedControl = {
let segmenetedControl = UISegmentedControl(items: ["A", "B"]) let segmenetedControl = UISegmentedControl(items: ["A", "B"])
@ -33,6 +36,8 @@ final class ProfileHeaderViewController: UIViewController {
// private var isAdjustBannerImageViewForSafeAreaInset = false // private var isAdjustBannerImageViewForSafeAreaInset = false
private var containerSafeAreaInset: UIEdgeInsets = .zero private var containerSafeAreaInset: UIEdgeInsets = .zero
let needsSetupBottomShadow = CurrentValueSubject<Bool, Never>(true)
deinit { deinit {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
@ -67,6 +72,14 @@ extension ProfileHeaderViewController {
]) ])
pageSegmentedControl.addTarget(self, action: #selector(ProfileHeaderViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged) pageSegmentedControl.addTarget(self, action: #selector(ProfileHeaderViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged)
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) { override func viewDidAppear(_ animated: Bool) {
@ -85,7 +98,7 @@ extension ProfileHeaderViewController {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
delegate?.profileHeaderViewController(self, viewLayoutDidUpdate: view) delegate?.profileHeaderViewController(self, viewLayoutDidUpdate: view)
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) setupBottomShadow()
} }
} }
@ -105,6 +118,15 @@ extension ProfileHeaderViewController {
containerSafeAreaInset = inset containerSafeAreaInset = inset
} }
func setupBottomShadow() {
guard 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) { private func updateHeaderBottomShadow(progress: CGFloat) {
let alpha = min(max(0, 10 * progress - 9), 1) let alpha = min(max(0, 10 * progress - 9), 1)
if bottomShadowAlpha != alpha { if bottomShadowAlpha != alpha {

View File

@ -34,7 +34,7 @@ extension ProfileRelationshipActionButton {
setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted) setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted)
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal)
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted)
setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .disabled) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled)
if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked { if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked {
isEnabled = false isEnabled = false

View File

@ -136,19 +136,24 @@ extension ProfileViewController {
navigationItem.titleView = UIView() navigationItem.titleView = UIView()
Publishers.CombineLatest3( Publishers.CombineLatest4(
viewModel.suspended.eraseToAnyPublisher(),
viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(), viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(),
viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(), viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(),
viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher() viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher()
) )
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in .sink { [weak self] suspended, isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in
guard let self = self else { return } guard let self = self else { return }
var items: [UIBarButtonItem] = [] var items: [UIBarButtonItem] = []
defer { defer {
self.navigationItem.rightBarButtonItems = !items.isEmpty ? items : nil self.navigationItem.rightBarButtonItems = !items.isEmpty ? items : nil
} }
guard !suspended else {
return
}
guard isMeBarButtonItemsHidden else { guard isMeBarButtonItemsHidden else {
items.append(self.settingBarButtonItem) items.append(self.settingBarButtonItem)
items.append(self.shareBarButtonItem) items.append(self.shareBarButtonItem)
@ -345,6 +350,21 @@ extension ProfileViewController {
} }
} }
.store(in: &disposeBag) .store(in: &disposeBag)
Publishers.CombineLatest3(
viewModel.isBlocking.eraseToAnyPublisher(),
viewModel.isBlockedBy.eraseToAnyPublisher(),
viewModel.suspended.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isBlocking, isBlockedBy, suspended in
guard let self = self else { return }
let isNeedSetHidden = isBlocking || isBlockedBy || suspended
self.profileHeaderViewController.needsSetupBottomShadow.value = !isNeedSetHidden
self.profileHeaderViewController.profileHeaderView.bioContainerView.isHidden = isNeedSetHidden
self.profileHeaderViewController.pageSegmentedControl.isHidden = isNeedSetHidden
self.viewModel.needsPagePinToTop.value = isNeedSetHidden
}
.store(in: &disposeBag)
viewModel.bioDescription viewModel.bioDescription
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] bio in .sink(receiveValue: { [weak self] bio in
@ -411,6 +431,8 @@ extension ProfileViewController {
viewModel.userID.assign(to: \.value, on: userTimelineViewModel.userID).store(in: &disposeBag) viewModel.userID.assign(to: \.value, on: userTimelineViewModel.userID).store(in: &disposeBag)
viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag) viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag)
viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag) viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag)
viewModel.suspended.assign(to: \.value, on: userTimelineViewModel.isSuspended).store(in: &disposeBag)
viewModel.name.assign(to: \.value, on: userTimelineViewModel.userDisplayName).store(in: &disposeBag)
} }
} }
@ -476,10 +498,15 @@ extension ProfileViewController: UIScrollViewDelegate {
contentOffsets.removeAll() contentOffsets.removeAll()
} else { } else {
containerScrollView.contentOffset.y = topMaxContentOffsetY containerScrollView.contentOffset.y = topMaxContentOffsetY
if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer { if viewModel.needsPagePinToTop.value {
let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y // do nothing
customScrollViewContainerController.scrollView.contentOffset.y = contentOffsetY } else {
if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer {
let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y
customScrollViewContainerController.scrollView.contentOffset.y = contentOffsetY
}
} }
} }
// elastically banner image // elastically banner image
@ -538,16 +565,14 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
switch relationshipAction { switch relationshipAction {
case .none: case .none:
break break
case .follow, .following: case .follow, .reqeust, .pending, .following:
UserProviderFacade.toggleUserFollowRelationship(provider: self) UserProviderFacade.toggleUserFollowRelationship(provider: self)
.sink { _ in .sink { _ in
// TODO: handle error
} receiveValue: { _ in } receiveValue: { _ in
// do nothing
} }
.store(in: &disposeBag) .store(in: &disposeBag)
case .pending:
break
case .muting: case .muting:
guard let mastodonUser = viewModel.mastodonUser.value else { return } guard let mastodonUser = viewModel.mastodonUser.value else { return }
let name = mastodonUser.displayNameWithFallback let name = mastodonUser.displayNameWithFallback

View File

@ -41,7 +41,7 @@ class ProfileViewModel: NSObject {
let followersCount: CurrentValueSubject<Int?, Never> let followersCount: CurrentValueSubject<Int?, Never>
let protected: CurrentValueSubject<Bool?, Never> let protected: CurrentValueSubject<Bool?, Never>
// let suspended: CurrentValueSubject<Bool, Never> let suspended: CurrentValueSubject<Bool, Never>
let relationshipActionOptionSet = CurrentValueSubject<RelationshipActionOptionSet, Never>(.none) let relationshipActionOptionSet = CurrentValueSubject<RelationshipActionOptionSet, Never>(.none)
let isEditing = CurrentValueSubject<Bool, Never>(false) let isEditing = CurrentValueSubject<Bool, Never>(false)
@ -55,6 +55,8 @@ class ProfileViewModel: NSObject {
let isMoreMenuBarButtonItemHidden = CurrentValueSubject<Bool, Never>(true) let isMoreMenuBarButtonItemHidden = CurrentValueSubject<Bool, Never>(true)
let isMeBarButtonItemsHidden = CurrentValueSubject<Bool, Never>(true) let isMeBarButtonItemsHidden = CurrentValueSubject<Bool, Never>(true)
let needsPagePinToTop = CurrentValueSubject<Bool, Never>(false)
init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) { init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) {
self.context = context self.context = context
self.mastodonUser = CurrentValueSubject(mastodonUser) self.mastodonUser = CurrentValueSubject(mastodonUser)
@ -62,7 +64,6 @@ class ProfileViewModel: NSObject {
self.userID = CurrentValueSubject(mastodonUser?.id) self.userID = CurrentValueSubject(mastodonUser?.id)
self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL()) self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL())
self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL()) self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL())
// self.protected = CurrentValueSubject(twitterUser?.protected)
self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback) self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback)
self.username = CurrentValueSubject(mastodonUser?.acctWithDomain) self.username = CurrentValueSubject(mastodonUser?.acctWithDomain)
self.bioDescription = CurrentValueSubject(mastodonUser?.note) self.bioDescription = CurrentValueSubject(mastodonUser?.note)
@ -71,6 +72,7 @@ class ProfileViewModel: NSObject {
self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) }) self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) })
self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) }) self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) })
self.protected = CurrentValueSubject(mastodonUser?.locked) self.protected = CurrentValueSubject(mastodonUser?.locked)
self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false)
super.init() super.init()
relationshipActionOptionSet relationshipActionOptionSet
@ -226,6 +228,7 @@ extension ProfileViewModel {
self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) } self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) }
self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) } self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) }
self.protected.value = mastodonUser?.locked self.protected.value = mastodonUser?.locked
self.suspended.value = mastodonUser?.suspended ?? false
} }
private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) { private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) {
@ -255,6 +258,14 @@ extension ProfileViewModel {
// set with follow action default // set with follow action default
var relationshipActionSet = RelationshipActionOptionSet([.follow]) var relationshipActionSet = RelationshipActionOptionSet([.follow])
if mastodonUser.locked {
relationshipActionSet.insert(.request)
}
if mastodonUser.suspended {
relationshipActionSet.insert(.suspended)
}
let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false
if isFollowing { if isFollowing {
relationshipActionSet.insert(.following) relationshipActionSet.insert(.following)
@ -308,11 +319,13 @@ extension ProfileViewModel {
enum RelationshipAction: Int, CaseIterable { enum RelationshipAction: Int, CaseIterable {
case none // set hide from UI case none // set hide from UI
case follow case follow
case reqeust
case pending case pending
case following case following
case muting case muting
case blocked case blocked
case blocking case blocking
case suspended
case edit case edit
case editing case editing
@ -327,11 +340,13 @@ extension ProfileViewModel {
static let none = RelationshipAction.none.option static let none = RelationshipAction.none.option
static let follow = RelationshipAction.follow.option static let follow = RelationshipAction.follow.option
static let request = RelationshipAction.reqeust.option
static let pending = RelationshipAction.pending.option static let pending = RelationshipAction.pending.option
static let following = RelationshipAction.following.option static let following = RelationshipAction.following.option
static let muting = RelationshipAction.muting.option static let muting = RelationshipAction.muting.option
static let blocked = RelationshipAction.blocked.option static let blocked = RelationshipAction.blocked.option
static let blocking = RelationshipAction.blocking.option static let blocking = RelationshipAction.blocking.option
static let suspended = RelationshipAction.suspended.option
static let edit = RelationshipAction.edit.option static let edit = RelationshipAction.edit.option
static let editing = RelationshipAction.editing.option static let editing = RelationshipAction.editing.option
@ -354,11 +369,13 @@ extension ProfileViewModel {
switch highPriorityAction { switch highPriorityAction {
case .none: return " " case .none: return " "
case .follow: return L10n.Common.Controls.Firendship.follow case .follow: return L10n.Common.Controls.Firendship.follow
case .reqeust: return L10n.Common.Controls.Firendship.request
case .pending: return L10n.Common.Controls.Firendship.pending case .pending: return L10n.Common.Controls.Firendship.pending
case .following: return L10n.Common.Controls.Firendship.following case .following: return L10n.Common.Controls.Firendship.following
case .muting: return L10n.Common.Controls.Firendship.muted case .muting: return L10n.Common.Controls.Firendship.muted
case .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user case .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user
case .blocking: return L10n.Common.Controls.Firendship.blocked case .blocking: return L10n.Common.Controls.Firendship.blocked
case .suspended: return L10n.Common.Controls.Firendship.follow
case .edit: return L10n.Common.Controls.Firendship.editInfo case .edit: return L10n.Common.Controls.Firendship.editInfo
case .editing: return L10n.Common.Controls.Actions.done case .editing: return L10n.Common.Controls.Actions.done
} }
@ -372,11 +389,13 @@ extension ProfileViewModel {
switch highPriorityAction { switch highPriorityAction {
case .none: return Asset.Colors.Button.normal.color case .none: return Asset.Colors.Button.normal.color
case .follow: return Asset.Colors.Button.normal.color case .follow: return Asset.Colors.Button.normal.color
case .reqeust: return Asset.Colors.Button.normal.color
case .pending: return Asset.Colors.Button.normal.color case .pending: return Asset.Colors.Button.normal.color
case .following: return Asset.Colors.Button.normal.color case .following: return Asset.Colors.Button.normal.color
case .muting: return Asset.Colors.Background.alertYellow.color case .muting: return Asset.Colors.Background.alertYellow.color
case .blocked: return Asset.Colors.Button.disabled.color case .blocked: return Asset.Colors.Button.normal.color
case .blocking: return Asset.Colors.Background.danger.color case .blocking: return Asset.Colors.Background.danger.color
case .suspended: return Asset.Colors.Button.normal.color
case .edit: return Asset.Colors.Button.normal.color case .edit: return Asset.Colors.Button.normal.color
case .editing: return Asset.Colors.Button.normal.color case .editing: return Asset.Colors.Button.normal.color
} }

View File

@ -27,6 +27,8 @@ final class UserTimelineViewModel {
let isBlocking = CurrentValueSubject<Bool, Never>(false) let isBlocking = CurrentValueSubject<Bool, Never>(false)
let isBlockedBy = 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
// output // output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
@ -59,14 +61,15 @@ final class UserTimelineViewModel {
.assign(to: \.value, on: statusFetchedResultsController.domain) .assign(to: \.value, on: statusFetchedResultsController.domain)
.store(in: &disposeBag) .store(in: &disposeBag)
Publishers.CombineLatest3( Publishers.CombineLatest4(
statusFetchedResultsController.objectIDs.eraseToAnyPublisher(), statusFetchedResultsController.objectIDs.eraseToAnyPublisher(),
isBlocking.eraseToAnyPublisher(), isBlocking.eraseToAnyPublisher(),
isBlockedBy.eraseToAnyPublisher() isBlockedBy.eraseToAnyPublisher(),
isSuspended.eraseToAnyPublisher()
) )
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] objectIDs, isBlocking, isBlockedBy in .sink { [weak self] objectIDs, isBlocking, isBlockedBy, isSuspended in
guard let self = self else { return } guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return } guard let diffableDataSource = self.diffableDataSource else { return }
@ -89,6 +92,12 @@ final class UserTimelineViewModel {
return return
} }
let name = self.userDisplayName.value
guard !isSuspended else {
snapshot.appendItems([Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .suspended(name: name)))], toSection: .main)
return
}
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
let oldSnapshot = diffableDataSource.snapshot() let oldSnapshot = diffableDataSource.snapshot()
for item in oldSnapshot.itemIdentifiers { for item in oldSnapshot.itemIdentifiers {

View File

@ -84,8 +84,10 @@ extension TimelineHeaderView {
extension Item.EmptyStateHeaderAttribute.Reason { extension Item.EmptyStateHeaderAttribute.Reason {
var iconImage: UIImage? { var iconImage: UIImage? {
switch self { switch self {
case .noStatusFound, .blocking, .blocked, .suspended: case .noStatusFound, .blocking, .blocked:
return UIImage(systemName: "nosign", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))! return UIImage(systemName: "nosign", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))!
case .suspended:
return UIImage(systemName: "person.crop.circle.badge.xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))!
} }
} }
@ -97,8 +99,12 @@ extension Item.EmptyStateHeaderAttribute.Reason {
return L10n.Common.Controls.Timeline.Header.blockingWarning return L10n.Common.Controls.Timeline.Header.blockingWarning
case .blocked: case .blocked:
return L10n.Common.Controls.Timeline.Header.blockedWarning return L10n.Common.Controls.Timeline.Header.blockedWarning
case .suspended: case .suspended(let name):
return L10n.Common.Controls.Timeline.Header.suspendedWarning if let name = name {
return L10n.Common.Controls.Timeline.Header.userSuspendedWarning(name)
} else {
return L10n.Common.Controls.Timeline.Header.suspendedWarning
}
} }
} }
} }

View File

@ -158,6 +158,7 @@ extension APIService {
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> { ) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> {
let domain = mastodonAuthenticationBox.domain let domain = mastodonAuthenticationBox.domain
let authorization = mastodonAuthenticationBox.userAuthorization let authorization = mastodonAuthenticationBox.userAuthorization
let requestMastodonUserID = mastodonAuthenticationBox.userID
return Mastodon.API.Account.follow( return Mastodon.API.Account.follow(
session: session, session: session,
@ -166,22 +167,50 @@ extension APIService {
followQueryType: followQueryType, followQueryType: followQueryType,
authorization: authorization authorization: authorization
) )
.handleEvents(receiveCompletion: { [weak self] completion in // .handleEvents(receiveCompletion: { [weak self] completion in
guard let _ = self else { return } // guard let _ = self else { return }
switch completion { // switch completion {
case .failure(let error): // case .failure(let error):
// TODO: handle error // // TODO: handle error
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update follow fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update follow fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
break // break
case .finished: // case .finished:
switch followQueryType { // switch followQueryType {
case .follow: // case .follow:
break // break
case .unfollow: // case .unfollow:
break // break
// }
// }
// })
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Relationship>, Error> in
let managedObjectContext = self.backgroundManagedObjectContext
return managedObjectContext.performChanges {
let requestMastodonUserRequest = MastodonUser.sortedFetchRequest
requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID)
requestMastodonUserRequest.fetchLimit = 1
guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return }
let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest
lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID)
lookUpMastodonUserRequest.fetchLimit = 1
let lookUpMastodonuser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first
if let lookUpMastodonuser = lookUpMastodonuser {
let entity = response.value
APIService.CoreData.update(user: lookUpMastodonuser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate)
} }
} }
}) .tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Relationship> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }

View File

@ -95,6 +95,9 @@ extension APIService.CoreData {
user.update(statusesCount: property.statusesCount) user.update(statusesCount: property.statusesCount)
user.update(followingCount: property.followingCount) user.update(followingCount: property.followingCount)
user.update(followersCount: property.followersCount) user.update(followersCount: property.followersCount)
user.update(locked: property.locked)
property.bot.flatMap { user.update(bot: $0) }
property.suspended.flatMap { user.update(suspended: $0) }
user.didUpdate(at: networkDate) user.didUpdate(at: networkDate)
} }