From 7f2f192995a3aeab2e0ddab11c19e432059318fe Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Tue, 2 Mar 2021 21:02:07 -0800 Subject: [PATCH] Select multiple statuses to report --- Localizations/Localizable.strings | 3 + Metatext.xcodeproj/project.pbxproj | 16 +- View Controllers/ReportViewController.swift | 112 +++++++++ View Controllers/TableViewController.swift | 36 +-- .../CollectionItemsViewModel.swift | 220 +++++++++--------- .../View Models/ReportViewModel.swift | 55 +++-- .../View Models/StatusViewModel.swift | 4 +- Views/SwiftUI/ReportView.swift | 99 -------- .../View Repesentables/ReportStatusView.swift | 28 --- Views/UIKit/Content Views/StatusView.swift | 20 +- Views/UIKit/ReportHeaderView.swift | 113 +++++++++ 11 files changed, 417 insertions(+), 289 deletions(-) create mode 100644 View Controllers/ReportViewController.swift delete mode 100644 Views/SwiftUI/ReportView.swift delete mode 100644 Views/SwiftUI/View Repesentables/ReportStatusView.swift create mode 100644 Views/UIKit/ReportHeaderView.swift diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 87ac517..946e79f 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -265,11 +265,14 @@ "report.target-%@" = "Report %@"; "report.forward.hint" = "The account is from another server. Send an anonymized copy of the report there as well?"; "report.forward-%@" = "Forward report to %@"; +"report.select-additional.hint.post" = "Select additional posts to report:"; +"report.select-additional.hint.toot" = "Select additional toots to report:"; "search.scope.all" = "All"; "search.scope.accounts" = "People"; "search.scope.statuses.post" = "Posts"; "search.scope.statuses.toot" = "Toots"; "search.scope.tags" = "Hashtags"; +"selected" = "Selected"; "send" = "Send"; "share" = "Share"; "share-extension-error.no-account-found" = "No account found"; diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index ef522af..c0686db 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ D0030982250C6C8500EACB32 /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0030981250C6C8500EACB32 /* URL+Extensions.swift */; }; + D005A1D825EF189A008B2E63 /* ReportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D005A1D725EF189A008B2E63 /* ReportViewController.swift */; }; + D005A1E625EF3D11008B2E63 /* ReportHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D005A1E525EF3D11008B2E63 /* ReportHeaderView.swift */; }; D00702292555E51200F38136 /* ConversationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00702282555E51200F38136 /* ConversationTableViewCell.swift */; }; D00702312555F4AE00F38136 /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00702302555F4AE00F38136 /* ConversationView.swift */; }; D00702362555F4C500F38136 /* ConversationContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */; }; @@ -132,7 +134,6 @@ D09D971825C64682007E6394 /* InstanceCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09D971725C64682007E6394 /* InstanceCollectionViewCell.swift */; }; D09D972225C65682007E6394 /* SeparatorConfiguredCollectionViewListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09D972125C65682007E6394 /* SeparatorConfiguredCollectionViewListCell.swift */; }; D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; }; - D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */; }; D0B325EB25E88ADC00C24BEA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D0C7D45724F76169001EBDBB /* Localizable.strings */; }; D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; }; D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */; }; @@ -187,7 +188,6 @@ D0D93ED025D9C9ED00C622ED /* AutocompleteItemCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D93ECF25D9C9ED00C622ED /* AutocompleteItemCollectionViewCell.swift */; }; D0D93ED925D9CBE200C622ED /* AutocompleteItemCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D93ECF25D9C9ED00C622ED /* AutocompleteItemCollectionViewCell.swift */; }; D0D93EDE25DA014700C622ED /* SeparatorConfiguredCollectionViewListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09D972125C65682007E6394 /* SeparatorConfiguredCollectionViewListCell.swift */; }; - D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DD50CA256B1F24004A04F7 /* ReportView.swift */; }; D0DDA76B25C5F20800FA0F91 /* ExploreDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DDA76A25C5F20800FA0F91 /* ExploreDataSource.swift */; }; D0DDA77525C5F73F00FA0F91 /* TagCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DDA77425C5F73F00FA0F91 /* TagCollectionViewCell.swift */; }; D0DDA77F25C6058300FA0F91 /* ExploreSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DDA77E25C6058300FA0F91 /* ExploreSectionHeaderView.swift */; }; @@ -260,6 +260,8 @@ /* Begin PBXFileReference section */ D0030981250C6C8500EACB32 /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = ""; }; + D005A1D725EF189A008B2E63 /* ReportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewController.swift; sourceTree = ""; }; + D005A1E525EF3D11008B2E63 /* ReportHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportHeaderView.swift; sourceTree = ""; }; D00702282555E51200F38136 /* ConversationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTableViewCell.swift; sourceTree = ""; }; D00702302555F4AE00F38136 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = ""; }; D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationContentConfiguration.swift; sourceTree = ""; }; @@ -353,7 +355,6 @@ D09D971725C64682007E6394 /* InstanceCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceCollectionViewCell.swift; sourceTree = ""; }; D09D972125C65682007E6394 /* SeparatorConfiguredCollectionViewListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorConfiguredCollectionViewListCell.swift; sourceTree = ""; }; D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = ""; }; - D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = ""; }; D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = ""; }; D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileCollection+Extensions.swift"; sourceTree = ""; }; D0B8510B25259E56004E0744 /* LoadMoreTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreTableViewCell.swift; sourceTree = ""; }; @@ -403,7 +404,6 @@ D0D93EB925D9C70400C622ED /* AutocompleteItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteItemView.swift; sourceTree = ""; }; D0D93EBF25D9C71D00C622ED /* AutocompleteItemContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteItemContentConfiguration.swift; sourceTree = ""; }; D0D93ECF25D9C9ED00C622ED /* AutocompleteItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteItemCollectionViewCell.swift; sourceTree = ""; }; - D0DD50CA256B1F24004A04F7 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = ""; }; D0DDA76A25C5F20800FA0F91 /* ExploreDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreDataSource.swift; sourceTree = ""; }; D0DDA77425C5F73F00FA0F91 /* TagCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagCollectionViewCell.swift; sourceTree = ""; }; D0DDA77E25C6058300FA0F91 /* ExploreSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreSectionHeaderView.swift; sourceTree = ""; }; @@ -509,6 +509,7 @@ D08B8D812544D80000B1EBEF /* PollOptionButton.swift */, D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */, D08B8D71254246E200B1EBEF /* PollView.swift */, + D005A1E525EF3D11008B2E63 /* ReportHeaderView.swift */, D08DFAF625CE20EA0005DA98 /* ScrollableToTop.swift */, D035F8C625B96A4000DC75ED /* SecondaryNavigationButton.swift */, D03D87F325C23C44004DCBB2 /* SecondaryNavigationTitleView.swift */, @@ -535,7 +536,6 @@ D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */, D0C7D42624F76169001EBDBB /* PreferencesView.swift */, D0B32F4F250B373600311912 /* RegistrationView.swift */, - D0DD50CA256B1F24004A04F7 /* ReportView.swift */, D0C7D42724F76169001EBDBB /* RootView.swift */, D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */, D021A66925C3E19D008A0C0D /* View Controller Representables */, @@ -621,7 +621,6 @@ D021A67225C3E2C8008A0C0D /* View Repesentables */ = { isa = PBXGroup; children = ( - D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */, ); path = "View Repesentables"; sourceTree = ""; @@ -758,6 +757,7 @@ D0FCC104259C4E61000B67DF /* NewStatusViewController.swift */, D097F4C025BFA04C00859F2C /* NotificationsViewController.swift */, D06BC5E525202AD90079541D /* ProfileViewController.swift */, + D005A1D725EF189A008B2E63 /* ReportViewController.swift */, D0F0B12D251A97E400942152 /* TableViewController.swift */, D035F87C25B7F61600DC75ED /* TimelinesViewController.swift */, ); @@ -1063,7 +1063,6 @@ D08B8D622540DE3B00B1EBEF /* ZoomTransitionController.swift in Sources */, D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */, D0477F2C25C6EBAD005C5368 /* OpenInDefaultBrowserActivity.swift in Sources */, - D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */, D07F4D9825D493E300F61133 /* MuteView.swift in Sources */, D02D338D25EDA593000A35CC /* CopyableLabel.swift in Sources */, D097F41B25BE3E1A00859F2C /* SearchScope+Extensions.swift in Sources */, @@ -1075,6 +1074,7 @@ D0CEC11025E3462B00FEF5A6 /* AnimatedAttachmentLabel.swift in Sources */, D05E688525B55AE8001FB2C6 /* AVURLAsset+Extensions.swift in Sources */, D0D93EC025D9C71D00C622ED /* AutocompleteItemContentConfiguration.swift in Sources */, + D005A1E625EF3D11008B2E63 /* ReportHeaderView.swift in Sources */, D09D970E25C64539007E6394 /* InstanceContentConfiguration.swift in Sources */, D036AA02254B6101009094DF /* NotificationTableViewCell.swift in Sources */, D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */, @@ -1094,6 +1094,7 @@ D021A61A25C36C1A008A0C0D /* IdentityContentConfiguration.swift in Sources */, D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */, D08B8D602540DE3B00B1EBEF /* ZoomAnimator.swift in Sources */, + D005A1D825EF189A008B2E63 /* ReportViewController.swift in Sources */, D08B8D672540DEB200B1EBEF /* ZoomAnimatableView.swift in Sources */, D0CEC0F725E3303200FEF5A6 /* AnimatingLayoutManager.swift in Sources */, D08B8D822544D80000B1EBEF /* PollOptionButton.swift in Sources */, @@ -1163,7 +1164,6 @@ D0FCC105259C4E61000B67DF /* NewStatusViewController.swift in Sources */, D087671625BAA8C0001FDD43 /* ExploreViewController.swift in Sources */, D0D2AC6725BD0484003D5DF2 /* LineChartView.swift in Sources */, - D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */, D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */, D097F4C125BFA04C00859F2C /* NotificationsViewController.swift in Sources */, D02D33EF25EE04CC000A35CC /* AddRemoveFromListsView.swift in Sources */, diff --git a/View Controllers/ReportViewController.swift b/View Controllers/ReportViewController.swift new file mode 100644 index 0000000..de9cef0 --- /dev/null +++ b/View Controllers/ReportViewController.swift @@ -0,0 +1,112 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Combine +import UIKit +import ViewModels + +final class ReportViewController: TableViewController { + private let reportButton = UIBarButtonItem( + title: nil, + style: .done, + target: nil, + action: nil) + private let activityIndicatorView = UIActivityIndicatorView(style: .large) + private let viewModel: ReportViewModel + private var cancellables = Set() + + init(viewModel: ReportViewModel) { + self.viewModel = viewModel + + super.init(viewModel: viewModel) + } + + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.title = String.localizedStringWithFormat( + NSLocalizedString("report.target-%@", comment: ""), + viewModel.accountName) + navigationItem.leftBarButtonItem = UIBarButtonItem( + systemItem: .cancel, + primaryAction: UIAction { [weak self] _ in self?.presentingViewController?.dismiss(animated: true) }) + navigationItem.rightBarButtonItem = reportButton + reportButton.primaryAction = UIAction(title: NSLocalizedString("report", comment: "")) { [weak self] _ in + self?.viewModel.report() + } + + tableView.tableHeaderView = ReportHeaderView(viewModel: viewModel) + + view.addSubview(activityIndicatorView) + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + activityIndicatorView.hidesWhenStopped = true + + viewModel.$reportingState + .sink { [weak self] in self?.apply(reportingState: $0) } + .store(in: &cancellables) + + NSLayoutConstraint.activate([ + activityIndicatorView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } + + override func tableView(_ tableView: UITableView, + willDisplay cell: UITableViewCell, + forRowAt indexPath: IndexPath) { + super.tableView(tableView, willDisplay: cell, forRowAt: indexPath) + + guard let statusView = cell.contentView as? StatusView else { return } + + statusView.alpha = 0.75 + statusView.buttonsStackView.isHidden = true + statusView.reportSelectionSwitch.isHidden = false + + for subview in statusView.subviews { + subview.isUserInteractionEnabled = false + } + } + + override func configureRightBarButtonItem(expandAllState: ExpandAllState) { + // no-op + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + guard let statusViewModel = viewModel.viewModel(indexPath: indexPath) as? StatusViewModel else { return } + + if viewModel.elements.statusIds.contains(statusViewModel.id) { + viewModel.elements.statusIds.remove(statusViewModel.id) + } else { + viewModel.elements.statusIds.insert(statusViewModel.id) + } + + let selectedForReport = viewModel.elements.statusIds.contains(statusViewModel.id) + + statusViewModel.selectedForReport = selectedForReport + + guard let statusView = tableView.cellForRow(at: indexPath)?.contentView as? StatusView else { return } + + statusView.reportSelectionSwitch.setOn(selectedForReport, animated: true) + statusView.refreshAccessibilityLabel() + } +} + +private extension ReportViewController { + func apply(reportingState: ReportViewModel.ReportingState) { + switch reportingState { + case .composing: + activityIndicatorView.stopAnimating() + view.isUserInteractionEnabled = true + reportButton.isEnabled = true + view.alpha = 1 + case .reporting: + activityIndicatorView.startAnimating() + view.isUserInteractionEnabled = false + reportButton.isEnabled = false + view.alpha = 0.5 + case .done: + presentingViewController?.dismiss(animated: true) + } + } +} diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index 3cd0f42..b1b368a 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -179,6 +179,23 @@ class TableViewController: UITableViewController { sizeTableHeaderFooterViews() } + + func configureRightBarButtonItem(expandAllState: ExpandAllState) { + switch expandAllState { + case .hidden: + navigationItem.rightBarButtonItem = nil + case .expand: + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: NSLocalizedString("status.show-more-all-button.accessibilty-label", comment: ""), + image: UIImage(systemName: "eye"), + primaryAction: UIAction { [weak self] _ in self?.viewModel.toggleExpandAll() }) + case .collapse: + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: NSLocalizedString("status.show-less-all-button.accessibilty-label", comment: ""), + image: UIImage(systemName: "eye.slash"), + primaryAction: UIAction { [weak self] _ in self?.viewModel.toggleExpandAll() }) + } + } } extension TableViewController { @@ -373,7 +390,7 @@ private extension TableViewController { .store(in: &cancellables) viewModel.expandAll.receive(on: DispatchQueue.main) - .sink { [weak self] in self?.set(expandAllState: $0) } + .sink { [weak self] in self?.configureRightBarButtonItem(expandAllState: $0) } .store(in: &cancellables) viewModel.loading.receive(on: DispatchQueue.main).assign(to: &$loading) @@ -741,23 +758,6 @@ private extension TableViewController { viewModel.applyAccountListEdit(viewModel: accountViewModel, edit: edit) } - func set(expandAllState: ExpandAllState) { - switch expandAllState { - case .hidden: - navigationItem.rightBarButtonItem = nil - case .expand: - navigationItem.rightBarButtonItem = UIBarButtonItem( - title: NSLocalizedString("status.show-more-all-button.accessibilty-label", comment: ""), - image: UIImage(systemName: "eye"), - primaryAction: UIAction { [weak self] _ in self?.viewModel.toggleExpandAll() }) - case .collapse: - navigationItem.rightBarButtonItem = UIBarButtonItem( - title: NSLocalizedString("status.show-less-all-button.accessibilty-label", comment: ""), - image: UIImage(systemName: "eye.slash"), - primaryAction: UIAction { [weak self] _ in self?.viewModel.toggleExpandAll() }) - } - } - func share(url: URL) { let activityViewController = UIActivityViewController( activityItems: [url], diff --git a/ViewModels/Sources/ViewModels/View Models/CollectionItemsViewModel.swift b/ViewModels/Sources/ViewModels/View Models/CollectionItemsViewModel.swift index 26c4736..38682a6 100644 --- a/ViewModels/Sources/ViewModels/View Models/CollectionItemsViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/CollectionItemsViewModel.swift @@ -95,116 +95,6 @@ public class CollectionItemsViewModel: ObservableObject { request(maxId: maxId, minId: nil, search: nil) } -} - -extension CollectionItemsViewModel: CollectionViewModel { - public var title: AnyPublisher { collectionService.title } - - public var titleLocalizationComponents: AnyPublisher<[String], Never> { - collectionService.titleLocalizationComponents - } - - public var expandAll: AnyPublisher { - expandAllSubject.eraseToAnyPublisher() - } - - public var alertItems: AnyPublisher { $alertItem.compactMap { $0 }.eraseToAnyPublisher() } - - public var loading: AnyPublisher { loadingSubject.eraseToAnyPublisher() } - - public var events: AnyPublisher { - eventsSubject.flatMap { [weak self] eventPublisher -> AnyPublisher in - guard let self = self else { return Empty().eraseToAnyPublisher() } - - return eventPublisher.assignErrorsToAlertItem(to: \.alertItem, on: self).eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } - - public var searchScopeChanges: AnyPublisher { searchScopeChangesSubject.eraseToAnyPublisher() } - - public var canRefresh: Bool { collectionService.canRefresh } - - public var announcesNewItems: Bool { collectionService.announcesNewItems } - - public func request(maxId: String? = nil, minId: String? = nil, search: Search?) { - collectionService.request(maxId: realMaxId(maxId: maxId), minId: minId, search: search) - .receive(on: DispatchQueue.main) - .assignErrorsToAlertItem(to: \.alertItem, on: self) - .handleEvents( - receiveSubscription: { [weak self] _ in self?.loadingSubject.send(true) }, - receiveCompletion: { [weak self] _ in self?.loadingSubject.send(false) }) - .sink { _ in } - .store(in: &cancellables) - collectionService.requestMarkerLastReadId() - .sink { _ in } receiveValue: { [weak self] in self?.markerLastReadId = $0 } - .store(in: &cancellables) - - } - - public func select(indexPath: IndexPath) { - let item = lastUpdate.sections[indexPath.section].items[indexPath.item] - - switch item { - case let .status(status, _, _): - send(event: .navigation(.collection(collectionService - .navigationService - .contextService(id: status.displayStatus.id)))) - case let .loadMore(loadMore): - lastSelectedLoadMore = loadMore - (viewModel(indexPath: indexPath) as? LoadMoreViewModel)?.loadMore() - case let .account(account, _, relationship): - send(event: .navigation(.profile(collectionService - .navigationService - .profileService(account: account, relationship: relationship)))) - case let .notification(notification, _): - if let status = notification.status { - send(event: .navigation(.collection(collectionService - .navigationService - .contextService(id: status.displayStatus.id)))) - } else { - send(event: .navigation(.profile(collectionService - .navigationService - .profileService(account: notification.account)))) - } - case let .conversation(conversation): - guard let status = conversation.lastStatus else { break } - - (collectionService as? ConversationsService)?.markConversationAsRead(id: conversation.id) - .sink { _ in } receiveValue: { _ in } - .store(in: &cancellables) - - send(event: .navigation(.collection(collectionService - .navigationService - .contextService(id: status.displayStatus.id)))) - case let .tag(tag): - send(event: .navigation(.collection(collectionService - .navigationService - .timelineService(timeline: .tag(tag.name))))) - case let .moreResults(moreResults): - searchScopeChangesSubject.send(moreResults.scope) - } - } - - public func viewedAtTop(indexPath: IndexPath) { - topVisibleIndexPath = indexPath - - if lastUpdate.sections.count > indexPath.section, - lastUpdate.sections[indexPath.section].items.count > indexPath.item { - lastReadId.send(lastUpdate.sections[indexPath.section].items[indexPath.item].itemId) - } - } - - public func canSelect(indexPath: IndexPath) -> Bool { - switch lastUpdate.sections[indexPath.section].items[indexPath.item] { - case let .status(_, configuration, _): - return !configuration.isContextParent - case .loadMore: - return !((viewModel(indexPath: indexPath) as? LoadMoreViewModel)?.loading ?? false) - default: - return true - } - } // swiftlint:disable:next function_body_length cyclomatic_complexity public func viewModel(indexPath: IndexPath) -> Any { @@ -317,6 +207,116 @@ extension CollectionItemsViewModel: CollectionViewModel { return viewModel } } +} + +extension CollectionItemsViewModel: CollectionViewModel { + public var title: AnyPublisher { collectionService.title } + + public var titleLocalizationComponents: AnyPublisher<[String], Never> { + collectionService.titleLocalizationComponents + } + + public var expandAll: AnyPublisher { + expandAllSubject.eraseToAnyPublisher() + } + + public var alertItems: AnyPublisher { $alertItem.compactMap { $0 }.eraseToAnyPublisher() } + + public var loading: AnyPublisher { loadingSubject.eraseToAnyPublisher() } + + public var events: AnyPublisher { + eventsSubject.flatMap { [weak self] eventPublisher -> AnyPublisher in + guard let self = self else { return Empty().eraseToAnyPublisher() } + + return eventPublisher.assignErrorsToAlertItem(to: \.alertItem, on: self).eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + public var searchScopeChanges: AnyPublisher { searchScopeChangesSubject.eraseToAnyPublisher() } + + public var canRefresh: Bool { collectionService.canRefresh } + + public var announcesNewItems: Bool { collectionService.announcesNewItems } + + public func request(maxId: String? = nil, minId: String? = nil, search: Search?) { + collectionService.request(maxId: realMaxId(maxId: maxId), minId: minId, search: search) + .receive(on: DispatchQueue.main) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .handleEvents( + receiveSubscription: { [weak self] _ in self?.loadingSubject.send(true) }, + receiveCompletion: { [weak self] _ in self?.loadingSubject.send(false) }) + .sink { _ in } + .store(in: &cancellables) + collectionService.requestMarkerLastReadId() + .sink { _ in } receiveValue: { [weak self] in self?.markerLastReadId = $0 } + .store(in: &cancellables) + + } + + public func select(indexPath: IndexPath) { + let item = lastUpdate.sections[indexPath.section].items[indexPath.item] + + switch item { + case let .status(status, _, _): + send(event: .navigation(.collection(collectionService + .navigationService + .contextService(id: status.displayStatus.id)))) + case let .loadMore(loadMore): + lastSelectedLoadMore = loadMore + (viewModel(indexPath: indexPath) as? LoadMoreViewModel)?.loadMore() + case let .account(account, _, relationship): + send(event: .navigation(.profile(collectionService + .navigationService + .profileService(account: account, relationship: relationship)))) + case let .notification(notification, _): + if let status = notification.status { + send(event: .navigation(.collection(collectionService + .navigationService + .contextService(id: status.displayStatus.id)))) + } else { + send(event: .navigation(.profile(collectionService + .navigationService + .profileService(account: notification.account)))) + } + case let .conversation(conversation): + guard let status = conversation.lastStatus else { break } + + (collectionService as? ConversationsService)?.markConversationAsRead(id: conversation.id) + .sink { _ in } receiveValue: { _ in } + .store(in: &cancellables) + + send(event: .navigation(.collection(collectionService + .navigationService + .contextService(id: status.displayStatus.id)))) + case let .tag(tag): + send(event: .navigation(.collection(collectionService + .navigationService + .timelineService(timeline: .tag(tag.name))))) + case let .moreResults(moreResults): + searchScopeChangesSubject.send(moreResults.scope) + } + } + + public func viewedAtTop(indexPath: IndexPath) { + topVisibleIndexPath = indexPath + + if lastUpdate.sections.count > indexPath.section, + lastUpdate.sections[indexPath.section].items.count > indexPath.item { + lastReadId.send(lastUpdate.sections[indexPath.section].items[indexPath.item].itemId) + } + } + + public func canSelect(indexPath: IndexPath) -> Bool { + switch lastUpdate.sections[indexPath.section].items[indexPath.item] { + case let .status(_, configuration, _): + return !configuration.isContextParent + case .loadMore: + return !((viewModel(indexPath: indexPath) as? LoadMoreViewModel)?.loading ?? false) + default: + return true + } + } public func toggleExpandAll() { let statusIds = Set(lastUpdate.sections.map(\.items).reduce([], +).compactMap { item -> Status.Id? in diff --git a/ViewModels/Sources/ViewModels/View Models/ReportViewModel.swift b/ViewModels/Sources/ViewModels/View Models/ReportViewModel.swift index 5cbbe6c..93d7680 100644 --- a/ViewModels/Sources/ViewModels/View Models/ReportViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/ReportViewModel.swift @@ -5,36 +5,44 @@ import Foundation import Mastodon import ServiceLayer -public final class ReportViewModel: ObservableObject { +public final class ReportViewModel: CollectionItemsViewModel { @Published public var elements: ReportElements - public let events: AnyPublisher - public let statusViewModel: StatusViewModel? - @Published public private(set) var loading = false - @Published public var alertItem: AlertItem? + @Published public private(set) var reportingState = ReportingState.composing private let accountService: AccountService - private let eventsSubject = PassthroughSubject() private var cancellables = Set() - public init(accountService: AccountService, statusService: StatusService? = nil, identityContext: IdentityContext) { + public init(accountService: AccountService, statusId: Status.Id? = nil, identityContext: IdentityContext) { self.accountService = accountService elements = ReportElements(accountId: accountService.account.id) - events = eventsSubject.eraseToAnyPublisher() - if let statusService = statusService { - statusViewModel = StatusViewModel(statusService: statusService, - identityContext: identityContext, - eventsSubject: .init()) - elements.statusIds.insert(statusService.status.displayStatus.id) - } else { - statusViewModel = nil + super.init( + collectionService: identityContext.service.navigationService.timelineService( + timeline: .profile(accountId: accountService.account.id, profileCollection: .statusesAndReplies)), + identityContext: identityContext) + + if let statusId = statusId { + elements.statusIds.insert(statusId) } } + + public override func viewModel(indexPath: IndexPath) -> Any { + let viewModel = super.viewModel(indexPath: indexPath) + + if let statusViewModel = viewModel as? StatusViewModel { + statusViewModel.showReportSelectionToggle = true + statusViewModel.selectedForReport = elements.statusIds.contains(statusViewModel.id) + } + + return viewModel + } } public extension ReportViewModel { - enum Event { - case reported + enum ReportingState { + case composing + case reporting + case done } var accountName: String { "@".appending(accountService.account.acct) } @@ -48,15 +56,16 @@ public extension ReportViewModel { func report() { accountService.report(elements) .receive(on: DispatchQueue.main) - .handleEvents(receiveSubscription: { [weak self] _ in self?.loading = true }) - .assignErrorsToAlertItem(to: \.alertItem, on: self) + .handleEvents(receiveSubscription: { [weak self] _ in self?.reportingState = .reporting }) .sink { [weak self] in guard let self = self else { return } - self.loading = false - - if $0 == .finished { - self.eventsSubject.send(.reported) + switch $0 { + case .finished: + self.reportingState = .done + case let .failure(error): + self.alertItem = AlertItem(error: error) + self.reportingState = .composing } } receiveValue: { _ in } .store(in: &cancellables) diff --git a/ViewModels/Sources/ViewModels/View Models/StatusViewModel.swift b/ViewModels/Sources/ViewModels/View Models/StatusViewModel.swift index d68045e..bc5b25e 100644 --- a/ViewModels/Sources/ViewModels/View Models/StatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/StatusViewModel.swift @@ -17,6 +17,8 @@ public final class StatusViewModel: AttachmentsRenderingViewModel, ObservableObj public let pollEmojis: [Emoji] @Published public var pollOptionSelections = Set() public var configuration = CollectionItem.StatusConfiguration.default + public var showReportSelectionToggle = false + public var selectedForReport = false public let identityContext: IdentityContext private let statusService: StatusService @@ -349,7 +351,7 @@ public extension StatusViewModel { Just(.report(ReportViewModel( accountService: statusService.navigationService.accountService( account: statusService.status.displayStatus.account), - statusService: statusService, + statusId: statusService.status.displayStatus.id, identityContext: identityContext))) .setFailureType(to: Error.self) .eraseToAnyPublisher()) diff --git a/Views/SwiftUI/ReportView.swift b/Views/SwiftUI/ReportView.swift deleted file mode 100644 index 1378313..0000000 --- a/Views/SwiftUI/ReportView.swift +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import SwiftUI -import ViewModels - -struct ReportView: View { - @StateObject var viewModel: ReportViewModel - - @Environment(\.presentationMode) private var presentationMode - fileprivate var dismissHostingController: (() -> Void)? - - var body: some View { - Form { - if let statusViewModel = viewModel.statusViewModel { - Section { - ReportStatusView(viewModel: statusViewModel) - .frame(height: Self.statusHeight) - } - } - Section { - Text("report.hint") - } - Section(header: Text("report.additional-comments")) { - TextEditor(text: $viewModel.elements.comment) - .accessibility(label: Text("report.additional-comments")) - } - if !viewModel.isLocalAccount { - Section { - VStack(alignment: .leading) { - Text("report.forward.hint") - Toggle("report.forward-\(viewModel.accountHost)", isOn: $viewModel.elements.forward) - } - } - } - Section { - if viewModel.loading { - ProgressView() - } else { - Button("report.target-\(viewModel.accountName)") { - viewModel.report() - } - } - } - } - .alertItem($viewModel.alertItem) - .onReceive(viewModel.events) { - switch $0 { - case .reported: - dismiss() - } - } - .navigationTitle("report.target-\(viewModel.accountName)") - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("cancel") { - dismiss() - } - } - } - .navigationBarTitleDisplayMode(.inline) - } -} - -private extension ReportView { - static let statusHeight: CGFloat = 100 - - func dismiss() { - if let dismissHostingController = dismissHostingController { - dismissHostingController() - } else { - presentationMode.wrappedValue.dismiss() - } - } -} - -final class ReportViewController: UIHostingController { - init(viewModel: ReportViewModel) { - super.init(rootView: ReportView(viewModel: viewModel)) - - rootView.dismissHostingController = { [weak self] in self?.dismiss(animated: true) } - } - - @available(*, unavailable) - @objc required dynamic init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -#if DEBUG -import PreviewViewModels - -struct ReportView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - ReportView(viewModel: .preview) - } - } -} -#endif diff --git a/Views/SwiftUI/View Repesentables/ReportStatusView.swift b/Views/SwiftUI/View Repesentables/ReportStatusView.swift deleted file mode 100644 index f5bd348..0000000 --- a/Views/SwiftUI/View Repesentables/ReportStatusView.swift +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import SwiftUI -import ViewModels - -struct ReportStatusView: UIViewRepresentable { - private let configuration: StatusContentConfiguration - - init(viewModel: StatusViewModel) { - configuration = StatusContentConfiguration(viewModel: viewModel) - } - - func makeUIView(context: Context) -> StatusView { - let view = StatusView(configuration: configuration) - - view.alpha = 0.5 - view.buttonsStackView.isHidden = true - view.translatesAutoresizingMaskIntoConstraints = false - view.isUserInteractionEnabled = false - view.accessibilityLabel = view.accessibilityAttributedLabel?.string - - return view - } - - func updateUIView(_ uiView: StatusView, context: Context) { - - } -} diff --git a/Views/UIKit/Content Views/StatusView.swift b/Views/UIKit/Content Views/StatusView.swift index 5fdf26a..3882960 100644 --- a/Views/UIKit/Content Views/StatusView.swift +++ b/Views/UIKit/Content Views/StatusView.swift @@ -29,6 +29,7 @@ final class StatusView: UIView { let shareButton = UIButton() let menuButton = UIButton() let buttonsStackView = UIStackView() + let reportSelectionSwitch = UISwitch() private let containerStackView = UIStackView() private let sideStackView = UIStackView() @@ -64,7 +65,7 @@ final class StatusView: UIView { } override func accessibilityActivate() -> Bool { - if !statusConfiguration.viewModel.shouldShowContent { + if reportSelectionSwitch.isHidden, !statusConfiguration.viewModel.shouldShowContent { statusConfiguration.viewModel.toggleShowContent() accessibilityAttributedLabel = accessibilityAttributedLabel(forceShowContent: true) @@ -103,6 +104,10 @@ extension StatusView { return height } + + func refreshAccessibilityLabel() { + accessibilityAttributedLabel = accessibilityAttributedLabel(forceShowContent: false) + } } extension StatusView: UIContentView { @@ -377,6 +382,11 @@ private extension StatusView { view.widthAnchor.constraint(equalToConstant: .hairline).isActive = true } + containerStackView.addArrangedSubview(reportSelectionSwitch) + reportSelectionSwitch.setContentCompressionResistancePriority(.required, for: .horizontal) + reportSelectionSwitch.setContentHuggingPriority(.required, for: .horizontal) + reportSelectionSwitch.isHidden = true + NSLayoutConstraint.activate([ containerStackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor), containerStackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), @@ -583,6 +593,8 @@ private extension StatusView { menuButton.isEnabled = isAuthenticated + reportSelectionSwitch.isOn = viewModel.selectedForReport + isAccessibilityElement = !viewModel.configuration.isContextParent accessibilityAttributedLabel = accessibilityAttributedLabel(forceShowContent: false) @@ -721,6 +733,10 @@ private extension StatusView { func accessibilityAttributedLabel(forceShowContent: Bool) -> NSAttributedString { let accessibilityAttributedLabel = NSMutableAttributedString(string: "") + if !reportSelectionSwitch.isHidden, reportSelectionSwitch.isOn { + accessibilityAttributedLabel.appendWithSeparator(NSLocalizedString("selected", comment: "")) + } + if !infoLabel.isHidden, let infoText = infoLabel.attributedText { accessibilityAttributedLabel.appendWithSeparator(infoText) } @@ -888,7 +904,7 @@ private extension StatusView { // swiftlint:disable:next function_body_length cyclomatic_complexity func accessibilityCustomActions(viewModel: StatusViewModel) -> [UIAccessibilityCustomAction] { - guard !viewModel.configuration.isContextParent else { + guard !viewModel.configuration.isContextParent, reportSelectionSwitch.isHidden else { return [] } diff --git a/Views/UIKit/ReportHeaderView.swift b/Views/UIKit/ReportHeaderView.swift new file mode 100644 index 0000000..62d587f --- /dev/null +++ b/Views/UIKit/ReportHeaderView.swift @@ -0,0 +1,113 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +final class ReportHeaderView: UIView { + private let viewModel: ReportViewModel + private let textView = UITextView() + + init(viewModel: ReportViewModel) { + self.viewModel = viewModel + + super.init(frame: .zero) + + initialSetup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension ReportHeaderView: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + viewModel.elements.comment = textView.text + } +} + +private extension ReportHeaderView { + // swiftlint:disable:next function_body_length + func initialSetup() { + let stackView = UIStackView() + + addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = .defaultSpacing + + let hintLabel = UILabel() + + stackView.addArrangedSubview(hintLabel) + hintLabel.adjustsFontForContentSizeCategory = true + hintLabel.font = .preferredFont(forTextStyle: .subheadline) + hintLabel.text = NSLocalizedString("report.hint", comment: "") + hintLabel.numberOfLines = 0 + + stackView.addArrangedSubview(textView) + textView.adjustsFontForContentSizeCategory = true + textView.font = .preferredFont(forTextStyle: .body) + textView.layer.borderWidth = .hairline + textView.layer.borderColor = UIColor.separator.cgColor + textView.layer.cornerRadius = .defaultCornerRadius + textView.delegate = self + textView.accessibilityLabel = NSLocalizedString("report.additional-comments", comment: "") + + if !viewModel.isLocalAccount { + let forwardHintLabel = UILabel() + + stackView.addArrangedSubview(forwardHintLabel) + forwardHintLabel.adjustsFontForContentSizeCategory = true + forwardHintLabel.font = .preferredFont(forTextStyle: .subheadline) + forwardHintLabel.text = NSLocalizedString("report.forward.hint", comment: "") + forwardHintLabel.numberOfLines = 0 + + let switchStackView = UIStackView() + + stackView.addArrangedSubview(switchStackView) + switchStackView.spacing = .defaultSpacing + + let switchLabel = UILabel() + + switchStackView.addArrangedSubview(switchLabel) + switchLabel.adjustsFontForContentSizeCategory = true + switchLabel.font = .preferredFont(forTextStyle: .headline) + switchLabel.text = String.localizedStringWithFormat( + NSLocalizedString("report.forward-%@", comment: ""), + viewModel.accountHost) + switchLabel.textAlignment = .right + switchLabel.numberOfLines = 0 + + let forwardSwitch = UISwitch() + + switchStackView.addArrangedSubview(forwardSwitch) + forwardSwitch.setContentHuggingPriority(.required, for: .horizontal) + forwardSwitch.setContentCompressionResistancePriority(.required, for: .horizontal) + forwardSwitch.addAction( + UIAction { [weak self] _ in self?.viewModel.elements.forward = forwardSwitch.isOn }, + for: .valueChanged) + } + + let selectAdditionalHintLabel = UILabel() + + stackView.addArrangedSubview(selectAdditionalHintLabel) + selectAdditionalHintLabel.adjustsFontForContentSizeCategory = true + selectAdditionalHintLabel.font = .preferredFont(forTextStyle: .subheadline) + selectAdditionalHintLabel.numberOfLines = 0 + + switch viewModel.identityContext.appPreferences.statusWord { + case .toot: + selectAdditionalHintLabel.text = NSLocalizedString("report.select-additional.hint.toot", comment: "") + case .post: + selectAdditionalHintLabel.text = NSLocalizedString("report.select-additional.hint.post", comment: "") + } + + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + stackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor), + stackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor), + textView.heightAnchor.constraint(equalToConstant: .minimumButtonDimension * 2) + ]) + } +}