feat: handle blocking and blocked state for profile

This commit is contained in:
CMK 2021-04-06 16:43:08 +08:00
parent 824d214ce7
commit 9612cc3902
20 changed files with 390 additions and 177 deletions

View File

@ -5,4 +5,16 @@ Mastodon localization template file
## How to contribute?
TBD
TBD
## How to maintains
```zsh
// enter workdir
cd Mastodon
// edit i18n json
open ./Localization/app.json
// update resource
update_localization.sh
```

View File

@ -86,6 +86,12 @@
"loader": {
"load_missing_posts": "Load missing posts",
"loading_missing_posts": "Loading missing posts..."
},
"header": {
"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.",
"blocked_warning": "You cant view Artbots profile\n until they unblock you.",
"suspended_warning": "This account is suspended."
}
}
},

View File

@ -244,7 +244,6 @@
DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */; };
DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; };
DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; };
DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488326034BD7008B817C /* APIService+Status.swift */; };
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */; };
DB9A489026035963008B817C /* APIService+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488F26035963008B817C /* APIService+Media.swift */; };
DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */; };
@ -295,6 +294,8 @@
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; };
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; };
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; };
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */; };
DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */; };
DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; };
DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
/* End PBXBuildFile section */
@ -647,6 +648,8 @@
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = "<group>"; };
DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = "<group>"; };
DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = "<group>"; };
DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderView.swift; sourceTree = "<group>"; };
DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = "<group>"; };
DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = "<group>"; };
EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = "<group>"; };
@ -780,6 +783,7 @@
2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */,
2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */,
DB87D44A2609C11900D12C0D /* PollOptionView.swift */,
DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */,
);
path = Content;
sourceTree = "<group>";
@ -982,6 +986,7 @@
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */,
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */,
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */,
DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */,
DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */,
);
path = TableviewCell;
@ -2022,6 +2027,7 @@
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */,
DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */,
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */,
DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */,
DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */,
5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */,
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */,
@ -2088,6 +2094,7 @@
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */,
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */,
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
@ -2153,7 +2160,6 @@
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */,
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */,
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,

View File

@ -116,7 +116,7 @@ extension SceneCoordinator {
if let navigationControllerVisibleViewController = presentingViewController.navigationController?.visibleViewController {
switch viewController {
case is ProfileViewController:
let barButtonItem = UIBarButtonItem(title: navigationControllerVisibleViewController.title, style: .plain, target: nil, action: nil)
let barButtonItem = UIBarButtonItem(title: navigationControllerVisibleViewController.navigationItem.title, style: .plain, target: nil, action: nil)
barButtonItem.tintColor = .white
navigationControllerVisibleViewController.navigationItem.backBarButtonItem = barButtonItem
default:

View File

@ -22,6 +22,8 @@ enum Item {
case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID)
case publicMiddleLoader(statusID: String)
case bottomLoader
case emptyStateHeader(attribute: EmptyStateHeaderAttribute)
}
protocol StatusContentWarningAttribute {
@ -56,6 +58,30 @@ extension Item {
}
}
}
class EmptyStateHeaderAttribute: Hashable {
let id = UUID()
let reason: Reason
enum Reason {
case noStatusFound
case blocking
case blocked
case suspended
}
init(reason: Reason) {
self.reason = reason
}
static func == (lhs: Item.EmptyStateHeaderAttribute, rhs: Item.EmptyStateHeaderAttribute) -> Bool {
return lhs.reason == rhs.reason
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
}
extension Item: Equatable {
@ -65,12 +91,14 @@ extension Item: Equatable {
return objectIDLeft == objectIDRight
case (.status(let objectIDLeft, _), .status(let objectIDRight, _)):
return objectIDLeft == objectIDRight
case (.bottomLoader, .bottomLoader):
return true
case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)):
return upperLeft == upperRight
case (.homeMiddleLoader(let upperLeft), .homeMiddleLoader(let upperRight)):
return upperLeft == upperRight
case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)):
return upperLeft == upperRight
case (.bottomLoader, .bottomLoader):
return true
case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)):
return attributeLeft == attributeRight
default:
return false
}
@ -84,14 +112,16 @@ extension Item: Hashable {
hasher.combine(objectID)
case .status(let objectID, _):
hasher.combine(objectID)
case .publicMiddleLoader(let upper):
hasher.combine(String(describing: Item.publicMiddleLoader.self))
hasher.combine(upper)
case .homeMiddleLoader(upperTimelineIndexAnchorObjectID: let upper):
hasher.combine(String(describing: Item.homeMiddleLoader.self))
hasher.combine(upper)
case .publicMiddleLoader(let upper):
hasher.combine(String(describing: Item.publicMiddleLoader.self))
hasher.combine(upper)
case .bottomLoader:
hasher.combine(String(describing: Item.bottomLoader.self))
case .emptyStateHeader(let attribute):
hasher.combine(attribute)
}
}
}

View File

@ -27,16 +27,16 @@ extension CategoryPickerSection {
cell.categoryView.titleLabel.text = item.title
cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in
if cell.isSelected {
cell.categoryView.bgView.backgroundColor = Asset.Colors.lightBrandBlue.color
cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0)
cell.categoryView.bgView.backgroundColor = Asset.Colors.brandBlue.color
cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0)
if case .all = item {
cell.categoryView.titleLabel.textColor = Asset.Colors.lightWhite.color
cell.categoryView.titleLabel.textColor = .white
}
} else {
cell.categoryView.bgView.backgroundColor = Asset.Colors.lightWhite.color
cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0)
cell.categoryView.bgView.backgroundColor = Asset.Colors.Background.systemBackground.color
cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0)
if case .all = item {
cell.categoryView.titleLabel.textColor = Asset.Colors.lightBrandBlue.color
cell.categoryView.titleLabel.textColor = Asset.Colors.brandBlue.color
}
}
}

View File

@ -79,12 +79,17 @@ extension StatusSection {
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.startAnimating()
return cell
case .emptyStateHeader(let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineHeaderTableViewCell.self), for: indexPath) as! TimelineHeaderTableViewCell
StatusSection.configureEmptyStateHeader(cell: cell, attribute: attribute)
return cell
}
}
}
}
extension StatusSection {
static func configure(
cell: StatusTableViewCell,
dependency: NeedsDependency,
@ -473,6 +478,14 @@ extension StatusSection {
snapshot.appendItems(pollItems, toSection: .main)
cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
}
static func configureEmptyStateHeader(
cell: TimelineHeaderTableViewCell,
attribute: Item.EmptyStateHeaderAttribute
) {
cell.timelineHeaderView.iconImageView.image = attribute.reason.iconImage
cell.timelineHeaderView.messageLabel.text = attribute.reason.message
}
}
extension StatusSection {

View File

@ -64,11 +64,8 @@ extension ActiveLabel {
/// account field
func configure(field: String) {
activeEntities.removeAll()
if let parseResult = try? MastodonField.parse(field: field) {
text = parseResult.value
activeEntities = parseResult.activeEntities
} else {
text = ""
}
let parseResult = MastodonField.parse(field: field)
text = parseResult.value
activeEntities = parseResult.activeEntities
}
}

View File

@ -172,6 +172,16 @@ internal enum L10n {
}
}
internal enum Timeline {
internal enum Header {
/// You cant view Artbots profile\n until they unblock you.
internal static let blockedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockedWarning")
/// You cant view Artbots profile\n until you unblock them.\nYour account looks like this to them.
internal static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning")
/// No Status Found
internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound")
/// This account is suspended.
internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning")
}
internal enum Loader {
/// Loading missing posts...
internal static let loadingMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadingMissingPosts")

View File

@ -66,9 +66,9 @@ extension StatusProviderFacade {
static func responseToStatusActiveLabelAction(provider: StatusProvider, cell: UITableViewCell, activeLabel: ActiveLabel, didTapEntity entity: ActiveEntity) {
switch entity.type {
case .hashtag(let text, let userInfo):
case .hashtag:
break
case .mention(let text, let userInfo):
case .mention(let text, _):
coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: text)
case .url(_, _, let url, _):
guard let url = URL(string: url) else { return }

View File

@ -54,6 +54,13 @@ Please check your internet connection.";
"Common.Controls.Status.StatusContentWarning" = "content warning";
"Common.Controls.Status.UserReblogged" = "%@ reblogged";
"Common.Controls.Status.UserRepliedTo" = "Replied to %@";
"Common.Controls.Timeline.Header.BlockedWarning" = "You cant view Artbots profile
until they unblock you.";
"Common.Controls.Timeline.Header.BlockingWarning" = "You cant view Artbots profile
until you unblock them.
Your account looks like this to them.";
"Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found";
"Common.Controls.Timeline.Header.SuspendedWarning" = "This account is suspended.";
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
"Common.Countable.Photo.Multiple" = "photos";

View File

@ -140,63 +140,17 @@ extension ProfileViewController {
}
.store(in: &disposeBag)
// Publishers.CombineLatest4(
// viewModel.muted.eraseToAnyPublisher(),
// viewModel.blocked.eraseToAnyPublisher(),
// viewModel.twitterUser.eraseToAnyPublisher(),
// context.authenticationService.activeTwitterAuthenticationBox.eraseToAnyPublisher()
// )
// .receive(on: DispatchQueue.main)
// .sink { [weak self] muted, blocked, twitterUser, activeTwitterAuthenticationBox in
// guard let self = self else { return }
// guard let twitterUser = twitterUser,
// let activeTwitterAuthenticationBox = activeTwitterAuthenticationBox,
// twitterUser.id != activeTwitterAuthenticationBox.twitterUserID else {
// self.navigationItem.rightBarButtonItems = []
// return
// }
//
// if #available(iOS 14.0, *) {
// self.moreMenuBarButtonItem.target = nil
// self.moreMenuBarButtonItem.action = nil
// self.moreMenuBarButtonItem.menu = UserProviderFacade.createMenuForUser(
// twitterUser: twitterUser,
// muted: muted,
// blocked: blocked,
// dependency: self
// )
// } else {
// // no menu supports for early version
// self.moreMenuBarButtonItem.target = self
// self.moreMenuBarButtonItem.action = #selector(ProfileViewController.moreMenuBarButtonItemPressed(_:))
// }
//
// var rightBarButtonItems: [UIBarButtonItem] = [self.moreMenuBarButtonItem]
// if muted {
// rightBarButtonItems.append(self.unmuteMenuBarButtonItem)
// }
//
// self.navigationItem.rightBarButtonItems = rightBarButtonItems
// }
// .store(in: &disposeBag)
overlayScrollView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged)
// drawerSidebarTransitionController = DrawerSidebarTransitionController(drawerSidebarTransitionableViewController: self)
let postsUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter())
viewModel.domain.assign(to: \.value, on: postsUserTimelineViewModel.domain).store(in: &disposeBag)
viewModel.userID.assign(to: \.value, on: postsUserTimelineViewModel.userID).store(in: &disposeBag)
bind(userTimelineViewModel: postsUserTimelineViewModel)
let repliesUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: true))
viewModel.domain.assign(to: \.value, on: repliesUserTimelineViewModel.domain).store(in: &disposeBag)
viewModel.userID.assign(to: \.value, on: repliesUserTimelineViewModel.userID).store(in: &disposeBag)
bind(userTimelineViewModel: repliesUserTimelineViewModel)
let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true))
viewModel.domain.assign(to: \.value, on: mediaUserTimelineViewModel.domain).store(in: &disposeBag)
viewModel.userID.assign(to: \.value, on: mediaUserTimelineViewModel.userID).store(in: &disposeBag)
bind(userTimelineViewModel: mediaUserTimelineViewModel)
profileSegmentedViewController.pagingViewController.viewModel = {
let profilePagingViewModel = ProfilePagingViewModel(
@ -275,7 +229,7 @@ extension ProfileViewController {
.receive(on: DispatchQueue.main)
.sink { [weak self] name in
guard let self = self else { return }
self.title = name
self.navigationItem.title = name
}
.store(in: &disposeBag)
@ -425,6 +379,17 @@ extension ProfileViewController {
}
extension ProfileViewController {
private func bind(userTimelineViewModel: UserTimelineViewModel) {
viewModel.domain.assign(to: \.value, on: userTimelineViewModel.domain).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.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag)
}
}
extension ProfileViewController {
@objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) {

View File

@ -27,6 +27,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency {
let tableView = UITableView()
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
tableView.register(TimelineHeaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineHeaderTableViewCell.self))
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
@ -100,9 +101,29 @@ extension UserTimelineViewController {
// MARK: - UITableViewDelegate
extension UserTimelineViewController: UITableViewDelegate {
// TODO: cache cell height
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return 200
guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
if case .bottomLoader = item {
return TimelineLoaderTableViewCell.cellHeight
} else {
return 200
}
}
// os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription)
return ceil(frame.height)
}
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
let key = item.hashValue
let frame = cell.frame
viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key))
}
}

View File

@ -31,8 +31,6 @@ extension UserTimelineViewModel.State {
switch stateClass {
case is Reloading.Type:
return viewModel.userID.value != nil
case is Suspended.Type:
return true
default:
return false
}
@ -48,10 +46,6 @@ extension UserTimelineViewModel.State {
return true
case is NoMore.Type:
return true
case is NotAuthorized.Type, is Blocked.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
@ -116,8 +110,6 @@ extension UserTimelineViewModel.State {
switch stateClass {
case is Reloading.Type, is LoadingMore.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
@ -129,8 +121,6 @@ extension UserTimelineViewModel.State {
switch stateClass {
case is Reloading.Type, is LoadingMore.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
@ -146,10 +136,6 @@ extension UserTimelineViewModel.State {
return true
case is NoMore.Type:
return true
case is NotAuthorized.Type, is Blocked.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
@ -188,7 +174,12 @@ extension UserTimelineViewModel.State {
)
.receive(on: DispatchQueue.main)
.sink { completion in
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
break
}
} receiveValue: { response in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
@ -210,53 +201,22 @@ extension UserTimelineViewModel.State {
.store(in: &viewModel.disposeBag)
}
}
class NotAuthorized: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
}
}
class Blocked: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
}
}
class Suspended: UserTimelineViewModel.State {
}
class NoMore: UserTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type:
return true
case is NotAuthorized.Type, is Blocked.Type:
return true
case is Suspended.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
guard let viewModel = viewModel else { return }
// trigger data source update
viewModel.statusFetchedResultsController.objectIDs.value = viewModel.statusFetchedResultsController.objectIDs.value
}
}
}

View File

@ -24,6 +24,10 @@ class UserTimelineViewModel: NSObject {
let userID: CurrentValueSubject<String?, Never>
let queryFilter: CurrentValueSubject<QueryFilter, Never>
let statusFetchedResultsController: StatusFetchedResultsController
var cellFrameCache = NSCache<NSNumber, NSValue>()
let isBlocking = CurrentValueSubject<Bool, Never>(false)
let isBlockedBy = CurrentValueSubject<Bool, Never>(false)
// output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
@ -34,9 +38,6 @@ class UserTimelineViewModel: NSObject {
State.Fail(viewModel: self),
State.Idle(viewModel: self),
State.LoadingMore(viewModel: self),
State.NotAuthorized(viewModel: self),
State.Blocked(viewModel: self),
State.Suspended(viewModel: self),
State.NoMore(viewModel: self),
])
stateMachine.enter(State.Initial.self)
@ -59,46 +60,64 @@ class UserTimelineViewModel: NSObject {
.assign(to: \.value, on: statusFetchedResultsController.domain)
.store(in: &disposeBag)
statusFetchedResultsController.objectIDs
.receive(on: DispatchQueue.main)
.sink { [weak self] objectIDs in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
// var isPermissionDenied = false
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
let oldSnapshot = diffableDataSource.snapshot()
for item in oldSnapshot.itemIdentifiers {
guard case let .status(objectID, attribute) = item else { continue }
oldSnapshotAttributeDict[objectID] = attribute
}
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
snapshot.appendSections([.main])
var items: [Item] = []
for objectID in objectIDs {
let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute()
items.append(.status(objectID: objectID, attribute: attribute))
}
snapshot.appendItems(items, toSection: .main)
if let currentState = self.stateMachine.currentState {
switch currentState {
case is State.Reloading, is State.LoadingMore, is State.Idle, is State.Fail:
snapshot.appendItems([.bottomLoader], toSection: .main)
// TODO: handle other states
default:
break
}
}
Publishers.CombineLatest3(
statusFetchedResultsController.objectIDs.eraseToAnyPublisher(),
isBlocking.eraseToAnyPublisher(),
isBlockedBy.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] objectIDs, isBlocking, isBlockedBy in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
var items: [Item] = []
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
snapshot.appendSections([.main])
defer {
// not animate when empty items fix loader first appear layout issue
diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty)
}
.store(in: &disposeBag)
guard !isBlocking else {
snapshot.appendItems([Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .blocking))], toSection: .main)
return
}
guard !isBlockedBy else {
snapshot.appendItems([Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .blocked))], toSection: .main)
return
}
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
let oldSnapshot = diffableDataSource.snapshot()
for item in oldSnapshot.itemIdentifiers {
guard case let .status(objectID, attribute) = item else { continue }
oldSnapshotAttributeDict[objectID] = attribute
}
for objectID in objectIDs {
let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute()
items.append(.status(objectID: objectID, attribute: attribute))
}
snapshot.appendItems(items, toSection: .main)
if let currentState = self.stateMachine.currentState {
switch currentState {
case is State.Reloading, is State.LoadingMore, is State.Idle, is State.Fail:
snapshot.appendItems([.bottomLoader], toSection: .main)
case is State.NoMore:
break
// TODO: handle other states
default:
break
}
}
}
.store(in: &disposeBag)
}
deinit {
@ -125,3 +144,4 @@ extension UserTimelineViewModel {
}
}

View File

@ -0,0 +1,122 @@
//
// TimelineHeaderView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-6.
//
final class TimelineHeaderView: UIView {
let iconImageView: UIImageView = {
let imageView = UIImageView()
imageView.tintColor = Asset.Colors.Label.secondary.color
return imageView
}()
let messageLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 17)
label.textAlignment = .center
label.textColor = Asset.Colors.Label.secondary.color
label.text = "info"
label.numberOfLines = 0
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension TimelineHeaderView {
private func _init() {
backgroundColor = .clear
let topPaddingView = UIView()
topPaddingView.translatesAutoresizingMaskIntoConstraints = false
addSubview(topPaddingView)
NSLayoutConstraint.activate([
topPaddingView.topAnchor.constraint(equalTo: topAnchor),
topPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor),
topPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
let containerStackView = UIStackView()
containerStackView.axis = .vertical
containerStackView.alignment = .center
containerStackView.distribution = .fill
containerStackView.spacing = 16
containerStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor),
containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
containerStackView.addArrangedSubview(iconImageView)
containerStackView.addArrangedSubview(messageLabel)
let bottomPaddingView = UIView()
bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
addSubview(bottomPaddingView)
NSLayoutConstraint.activate([
bottomPaddingView.topAnchor.constraint(equalTo: containerStackView.bottomAnchor),
bottomPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor),
bottomPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomPaddingView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
NSLayoutConstraint.activate([
topPaddingView.heightAnchor.constraint(equalToConstant: 100).priority(.defaultHigh),
bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 1.0),
])
}
}
extension Item.EmptyStateHeaderAttribute.Reason {
var iconImage: UIImage? {
switch self {
case .noStatusFound, .blocking, .blocked, .suspended:
return UIImage(systemName: "nosign", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))!
}
}
var message: String {
switch self {
case .noStatusFound:
return L10n.Common.Controls.Timeline.Header.noStatusFound
case .blocking:
return L10n.Common.Controls.Timeline.Header.blockingWarning
case .blocked:
return L10n.Common.Controls.Timeline.Header.blockedWarning
case .suspended:
return L10n.Common.Controls.Timeline.Header.suspendedWarning
}
}
}
#if DEBUG && canImport(SwiftUI)
import SwiftUI
struct TimelineHeaderView_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview(width: 375) {
let headerView = TimelineHeaderView()
headerView.iconImageView.image = Item.EmptyStateHeaderAttribute.Reason.blocking.iconImage
headerView.messageLabel.text = Item.EmptyStateHeaderAttribute.Reason.blocking.message
return headerView
}
.previewLayout(.fixed(width: 375, height: 400))
}
}
}
#endif

View File

@ -0,0 +1,42 @@
//
// TimelineHeaderTableViewCell.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-6.
//
import UIKit
final class TimelineHeaderTableViewCell: UITableViewCell {
let timelineHeaderView = TimelineHeaderView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension TimelineHeaderTableViewCell {
private func _init() {
selectionStyle = .none
backgroundColor = .clear
timelineHeaderView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(timelineHeaderView)
NSLayoutConstraint.activate([
timelineHeaderView.topAnchor.constraint(equalTo: contentView.topAnchor),
timelineHeaderView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
timelineHeaderView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
timelineHeaderView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
}
}

View File

@ -145,11 +145,12 @@ extension APIService {
authorization: authorization
)
.handleEvents(receiveCompletion: { [weak self] completion in
guard let self = self else { return }
guard let _ = self else { return }
switch completion {
case .failure(let error):
// TODO: handle error
break
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] block update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
// TODO: update relationship
switch blockQueryType {

View File

@ -167,10 +167,11 @@ extension APIService {
authorization: authorization
)
.handleEvents(receiveCompletion: { [weak self] completion in
guard let self = self else { return }
guard let _ = self else { return }
switch completion {
case .failure(let 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)
break
case .finished:
switch followQueryType {

View File

@ -145,11 +145,11 @@ extension APIService {
authorization: authorization
)
.handleEvents(receiveCompletion: { [weak self] completion in
guard let self = self else { return }
guard let _ = self else { return }
switch completion {
case .failure(let error):
// TODO: handle error
break
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] Mute update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
// TODO: update relationship
switch muteQueryType {