diff --git a/Localization/README.md b/Localization/README.md index 1e6975f8b..b6baf1788 100644 --- a/Localization/README.md +++ b/Localization/README.md @@ -5,4 +5,16 @@ Mastodon localization template file ## How to contribute? -TBD \ No newline at end of file +TBD + +## How to maintains + +```zsh +// enter workdir +cd Mastodon +// edit i18n json +open ./Localization/app.json +// update resource +update_localization.sh + +``` \ No newline at end of file diff --git a/Localization/app.json b/Localization/app.json index 9eaba58f4..ce963936f 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -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." } } }, diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 260366a79..25ad03f3a 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; + DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = ""; }; + DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderView.swift; sourceTree = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; 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 = ""; }; @@ -780,6 +783,7 @@ 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */, 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */, DB87D44A2609C11900D12C0D /* PollOptionView.swift */, + DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */, ); path = Content; sourceTree = ""; @@ -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 */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 4b6eed7ba..67d5142c8 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -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: diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index cd07c8836..0a27f1871 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -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) } } } diff --git a/Mastodon/Diffiable/Section/CategoryPickerSection.swift b/Mastodon/Diffiable/Section/CategoryPickerSection.swift index 2164d9ebc..52443a13d 100644 --- a/Mastodon/Diffiable/Section/CategoryPickerSection.swift +++ b/Mastodon/Diffiable/Section/CategoryPickerSection.swift @@ -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 } } } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 5e891e13a..fe720e0f0 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -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 { diff --git a/Mastodon/Extension/ActiveLabel.swift b/Mastodon/Extension/ActiveLabel.swift index 614735ad1..66452e23e 100644 --- a/Mastodon/Extension/ActiveLabel.swift +++ b/Mastodon/Extension/ActiveLabel.swift @@ -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 } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 53ef603e2..7dd334d5a 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -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") diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index fa9ce3adf..37db1d853 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -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 } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 4a9b7bd30..0efa7376e 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 315a49427..1fe7a908a 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -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) { diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index 88134f1e1..442f57cce 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -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)) } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index 520fa43e5..0caa4a20c 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -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 + } } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index a550dc829..2276db5fe 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -24,6 +24,10 @@ class UserTimelineViewModel: NSObject { let userID: CurrentValueSubject let queryFilter: CurrentValueSubject let statusFetchedResultsController: StatusFetchedResultsController + var cellFrameCache = NSCache() + + let isBlocking = CurrentValueSubject(false) + let isBlockedBy = CurrentValueSubject(false) // output var diffableDataSource: UITableViewDiffableDataSource? @@ -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() - 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() + 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 { } } + diff --git a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift new file mode 100644 index 000000000..e253b3ca7 --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift @@ -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 diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineHeaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineHeaderTableViewCell.swift new file mode 100644 index 000000000..ba1b6b103 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineHeaderTableViewCell.swift @@ -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), + ]) + } + +} diff --git a/Mastodon/Service/APIService/APIService+Block.swift b/Mastodon/Service/APIService/APIService+Block.swift index ccd17c612..124b65155 100644 --- a/Mastodon/Service/APIService/APIService+Block.swift +++ b/Mastodon/Service/APIService/APIService+Block.swift @@ -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 { diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift index f52aae999..f2c57db57 100644 --- a/Mastodon/Service/APIService/APIService+Follow.swift +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -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 { diff --git a/Mastodon/Service/APIService/APIService+Mute.swift b/Mastodon/Service/APIService/APIService+Mute.swift index 2f9303261..9d992ab6a 100644 --- a/Mastodon/Service/APIService/APIService+Mute.swift +++ b/Mastodon/Service/APIService/APIService+Mute.swift @@ -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 {