forked from zelo72/mastodon-ios
feat: handle blocking and blocked state for profile
This commit is contained in:
parent
824d214ce7
commit
9612cc3902
|
@ -6,3 +6,15 @@ Mastodon localization template file
|
|||
## How to contribute?
|
||||
|
||||
TBD
|
||||
|
||||
## How to maintains
|
||||
|
||||
```zsh
|
||||
// enter workdir
|
||||
cd Mastodon
|
||||
// edit i18n json
|
||||
open ./Localization/app.json
|
||||
// update resource
|
||||
update_localization.sh
|
||||
|
||||
```
|
|
@ -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 can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them.",
|
||||
"blocked_warning": "You can’t view Artbot’s profile\n until they unblock you.",
|
||||
"suspended_warning": "This account is suspended."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -64,11 +64,8 @@ extension ActiveLabel {
|
|||
/// account field
|
||||
func configure(field: String) {
|
||||
activeEntities.removeAll()
|
||||
if let parseResult = try? MastodonField.parse(field: field) {
|
||||
let parseResult = MastodonField.parse(field: field)
|
||||
text = parseResult.value
|
||||
activeEntities = parseResult.activeEntities
|
||||
} else {
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -172,6 +172,16 @@ internal enum L10n {
|
|||
}
|
||||
}
|
||||
internal enum Timeline {
|
||||
internal enum Header {
|
||||
/// You can’t view Artbot’s profile\n until they unblock you.
|
||||
internal static let blockedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockedWarning")
|
||||
/// You can’t view Artbot’s 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")
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 can’t view Artbot’s profile
|
||||
until they unblock you.";
|
||||
"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view Artbot’s 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";
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,10 +101,30 @@ extension UserTimelineViewController {
|
|||
// MARK: - UITableViewDelegate
|
||||
extension UserTimelineViewController: UITableViewDelegate {
|
||||
|
||||
// TODO: cache cell height
|
||||
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
@ -211,52 +202,21 @@ extension UserTimelineViewModel.State {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,14 +60,35 @@ class UserTimelineViewModel: NSObject {
|
|||
.assign(to: \.value, on: statusFetchedResultsController.domain)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
|
||||
statusFetchedResultsController.objectIDs
|
||||
Publishers.CombineLatest3(
|
||||
statusFetchedResultsController.objectIDs.eraseToAnyPublisher(),
|
||||
isBlocking.eraseToAnyPublisher(),
|
||||
isBlockedBy.eraseToAnyPublisher()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] objectIDs in
|
||||
.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 isPermissionDenied = false
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
|
@ -75,10 +97,6 @@ class UserTimelineViewModel: NSObject {
|
|||
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))
|
||||
|
@ -89,14 +107,15 @@ class UserTimelineViewModel: NSObject {
|
|||
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
|
||||
}
|
||||
}
|
||||
|
||||
// not animate when empty items fix loader first appear layout issue
|
||||
diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty)
|
||||
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
@ -125,3 +144,4 @@ extension UserTimelineViewModel {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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),
|
||||
])
|
||||
}
|
||||
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue