Select multiple statuses to report

This commit is contained in:
Justin Mazzocchi 2021-03-02 21:02:07 -08:00
parent 5b360c6bc1
commit 7f2f192995
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
11 changed files with 417 additions and 289 deletions

View file

@ -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";

View file

@ -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 = "<group>"; };
D005A1D725EF189A008B2E63 /* ReportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewController.swift; sourceTree = "<group>"; };
D005A1E525EF3D11008B2E63 /* ReportHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportHeaderView.swift; sourceTree = "<group>"; };
D00702282555E51200F38136 /* ConversationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTableViewCell.swift; sourceTree = "<group>"; };
D00702302555F4AE00F38136 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = "<group>"; };
D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationContentConfiguration.swift; sourceTree = "<group>"; };
@ -353,7 +355,6 @@
D09D971725C64682007E6394 /* InstanceCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceCollectionViewCell.swift; sourceTree = "<group>"; };
D09D972125C65682007E6394 /* SeparatorConfiguredCollectionViewListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorConfiguredCollectionViewListCell.swift; sourceTree = "<group>"; };
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = "<group>"; };
D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = "<group>"; };
D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = "<group>"; };
D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileCollection+Extensions.swift"; sourceTree = "<group>"; };
D0B8510B25259E56004E0744 /* LoadMoreTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreTableViewCell.swift; sourceTree = "<group>"; };
@ -403,7 +404,6 @@
D0D93EB925D9C70400C622ED /* AutocompleteItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteItemView.swift; sourceTree = "<group>"; };
D0D93EBF25D9C71D00C622ED /* AutocompleteItemContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteItemContentConfiguration.swift; sourceTree = "<group>"; };
D0D93ECF25D9C9ED00C622ED /* AutocompleteItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteItemCollectionViewCell.swift; sourceTree = "<group>"; };
D0DD50CA256B1F24004A04F7 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = "<group>"; };
D0DDA76A25C5F20800FA0F91 /* ExploreDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreDataSource.swift; sourceTree = "<group>"; };
D0DDA77425C5F73F00FA0F91 /* TagCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagCollectionViewCell.swift; sourceTree = "<group>"; };
D0DDA77E25C6058300FA0F91 /* ExploreSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreSectionHeaderView.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 */,

View file

@ -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<AnyCancellable>()
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)
}
}
}

View file

@ -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],

View file

@ -95,116 +95,6 @@ public class CollectionItemsViewModel: ObservableObject {
request(maxId: maxId, minId: nil, search: nil)
}
}
extension CollectionItemsViewModel: CollectionViewModel {
public var title: AnyPublisher<String, Never> { collectionService.title }
public var titleLocalizationComponents: AnyPublisher<[String], Never> {
collectionService.titleLocalizationComponents
}
public var expandAll: AnyPublisher<ExpandAllState, Never> {
expandAllSubject.eraseToAnyPublisher()
}
public var alertItems: AnyPublisher<AlertItem, Never> { $alertItem.compactMap { $0 }.eraseToAnyPublisher() }
public var loading: AnyPublisher<Bool, Never> { loadingSubject.eraseToAnyPublisher() }
public var events: AnyPublisher<CollectionItemEvent, Never> {
eventsSubject.flatMap { [weak self] eventPublisher -> AnyPublisher<CollectionItemEvent, Never> in
guard let self = self else { return Empty().eraseToAnyPublisher() }
return eventPublisher.assignErrorsToAlertItem(to: \.alertItem, on: self).eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
public var searchScopeChanges: AnyPublisher<SearchScope, Never> { 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<String, Never> { collectionService.title }
public var titleLocalizationComponents: AnyPublisher<[String], Never> {
collectionService.titleLocalizationComponents
}
public var expandAll: AnyPublisher<ExpandAllState, Never> {
expandAllSubject.eraseToAnyPublisher()
}
public var alertItems: AnyPublisher<AlertItem, Never> { $alertItem.compactMap { $0 }.eraseToAnyPublisher() }
public var loading: AnyPublisher<Bool, Never> { loadingSubject.eraseToAnyPublisher() }
public var events: AnyPublisher<CollectionItemEvent, Never> {
eventsSubject.flatMap { [weak self] eventPublisher -> AnyPublisher<CollectionItemEvent, Never> in
guard let self = self else { return Empty().eraseToAnyPublisher() }
return eventPublisher.assignErrorsToAlertItem(to: \.alertItem, on: self).eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
public var searchScopeChanges: AnyPublisher<SearchScope, Never> { 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

View file

@ -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<Event, Never>
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<Event, Never>()
private var cancellables = Set<AnyCancellable>()
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)

View file

@ -17,6 +17,8 @@ public final class StatusViewModel: AttachmentsRenderingViewModel, ObservableObj
public let pollEmojis: [Emoji]
@Published public var pollOptionSelections = Set<Int>()
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())

View file

@ -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<ReportView> {
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

View file

@ -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) {
}
}

View file

@ -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 []
}

View file

@ -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)
])
}
}