diff --git a/Localization/app.json b/Localization/app.json index 1f5ccade3..96f366933 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -53,7 +53,9 @@ "share_user": "Share %s", "open_in_safari": "Open in Safari", "find_people": "Find people to follow", - "manually_search": "Manually search instead" + "manually_search": "Manually search instead", + "skip": "Skip", + "report_user": "Report %s" }, "status": { "user_reblogged": "%s reblogged", @@ -348,7 +350,7 @@ "reblog": "rebloged your post", "poll": "Your poll has ended", "mention": "mentioned you" - }, + } }, "thread": { "back_title": "Post", @@ -396,6 +398,16 @@ "signout": "Sign Out" } } + }, + "report": { + "title": "Report %s", + "step1": "Step 1 of 2", + "step2": "Step 2 of 2", + "content1": "Are there any other posts you’d like to add to the report?", + "content2": "Is there anything the moderators should know about this report?", + "send": "Send Report", + "skip_to_send": "Send without comment", + "text_placeholder": "Type or paste additional comments" } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d9af89e1b..041a837c5 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -139,6 +139,10 @@ 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; 2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; }; 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; }; + 5B24BBDA262DB14800A9381B /* ReportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */; }; + 5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */; }; + 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBE1262DB19100A9381B /* APIService+Report.swift */; }; + 5B8E055826319E47006E3C53 /* ReportFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8E055726319E47006E3C53 /* ReportFooterView.swift */; }; 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C456262599800002E742 /* SettingsViewModel.swift */; }; 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */; }; 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */; }; @@ -150,6 +154,11 @@ 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */; }; 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */; }; 5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */; }; + 5BB04FD5262E7AFF0043BFF6 /* ReportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */; }; + 5BB04FDB262EA3070043BFF6 /* ReportHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FDA262EA3070043BFF6 /* ReportHeaderView.swift */; }; + 5BB04FE9262EFC300043BFF6 /* ReportedStatusTableviewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */; }; + 5BB04FEF262F0DCB0043BFF6 /* ReportViewModel+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */; }; + 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */; }; 5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; }; 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; @@ -561,6 +570,10 @@ 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; + 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReportViewModel.swift; sourceTree = ""; }; + 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Diffable.swift"; sourceTree = ""; }; + 5B24BBE1262DB19100A9381B /* APIService+Report.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Report.swift"; sourceTree = ""; }; + 5B8E055726319E47006E3C53 /* ReportFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportFooterView.swift; sourceTree = ""; }; 5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsToggleTableViewCell.swift; sourceTree = ""; }; 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = ""; }; @@ -572,6 +585,11 @@ 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; }; 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Subscriptions.swift"; sourceTree = ""; }; 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Subscriptions.swift"; sourceTree = ""; }; + 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewController.swift; sourceTree = ""; }; + 5BB04FDA262EA3070043BFF6 /* ReportHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportHeaderView.swift; sourceTree = ""; }; + 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportedStatusTableviewCell.swift; sourceTree = ""; }; + 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Data.swift"; sourceTree = ""; }; + 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportSection.swift; sourceTree = ""; }; 5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; 5D0393952612D266007FE196 /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = ""; }; 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Remove.swift"; sourceTree = ""; }; @@ -1141,6 +1159,7 @@ 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, + 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, ); path = Section; sourceTree = ""; @@ -1264,6 +1283,20 @@ name = Frameworks; sourceTree = ""; }; + 5B24BBD6262DB14800A9381B /* Report */ = { + isa = PBXGroup; + children = ( + 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */, + 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */, + 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */, + 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */, + 5BB04FDA262EA3070043BFF6 /* ReportHeaderView.swift */, + 5B8E055726319E47006E3C53 /* ReportFooterView.swift */, + 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */, + ); + path = Report; + sourceTree = ""; + }; 5B90C455262599800002E742 /* Settings */ = { isa = PBXGroup; children = ( @@ -1476,6 +1509,7 @@ 2D34D9DA261494120081BFC0 /* APIService+Search.swift */, 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */, DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */, + 5B24BBE1262DB19100A9381B /* APIService+Report.swift */, DBAE3F932616E28B004B8251 /* APIService+Follow.swift */, DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */, DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */, @@ -1715,6 +1749,7 @@ DB01409B25C40BB600F9F3CF /* Onboarding */, 2D38F1D325CD463600561493 /* HomeTimeline */, 2D76316325C14BAC00929FB9 /* PublicTimeline */, + 5B24BBD6262DB14800A9381B /* Report */, 0F2021F5261325ED000C64BF /* HashtagTimeline */, 2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */, DB9D6BEE25E4F5370051B173 /* Search */, @@ -2375,10 +2410,13 @@ 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */, DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */, + 5BB04FEF262F0DCB0043BFF6 /* ReportViewModel+Data.swift in Sources */, + 5B8E055826319E47006E3C53 /* ReportFooterView.swift in Sources */, DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */, 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */, + 5B24BBDA262DB14800A9381B /* ReportViewModel.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */, DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */, @@ -2490,6 +2528,7 @@ 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */, DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, + 5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */, DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, @@ -2541,6 +2580,7 @@ DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, 2D61254D262547C200299647 /* APIService+Notification.swift in Sources */, DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, + 5BB04FE9262EFC300043BFF6 /* ReportedStatusTableviewCell.swift in Sources */, DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */, DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, @@ -2548,6 +2588,7 @@ DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, + 5BB04FDB262EA3070043BFF6 /* ReportHeaderView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, @@ -2649,6 +2690,8 @@ DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, + 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */, + 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */, DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, @@ -2660,6 +2703,7 @@ DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */, + 5BB04FD5262E7AFF0043BFF6 /* ReportViewController.swift in Sources */, DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index c0ad695f0..95d50dca7 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -68,10 +68,10 @@ extension SceneCoordinator { case safari(url: URL) case alertController(alertController: UIAlertController) case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?) - + case settings(viewModel: SettingsViewModel) + case report(viewModel: ReportViewModel) #if DEBUG case publicTimeline - case settings #endif var isOnboarding: Bool { @@ -276,15 +276,19 @@ private extension SceneCoordinator { activityViewController.popoverPresentationController?.sourceView = sourceView activityViewController.popoverPresentationController?.barButtonItem = barButtonItem viewController = activityViewController + case .settings(let viewModel): + let _viewController = SettingsViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .report(let viewModel): + let _viewController = ReportViewController() + _viewController.viewModel = viewModel + viewController = _viewController #if DEBUG case .publicTimeline: let _viewController = PublicTimelineViewController() _viewController.viewModel = PublicTimelineViewModel(context: appContext) viewController = _viewController - case .settings: - let _viewController = SettingsViewController() - _viewController.viewModel = SettingsViewModel(context: appContext, coordinator: self) - viewController = _viewController #endif } diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index e169be66f..04a1262d5 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -32,6 +32,9 @@ enum Item { case bottomLoader case emptyStateHeader(attribute: EmptyStateHeaderAttribute) + + // reports + case reportStatus(objectID: NSManagedObjectID, attribute: ReportStatusAttribute) } extension Item { @@ -79,6 +82,15 @@ extension Item { hasher.combine(id) } } + + class ReportStatusAttribute: StatusAttribute { + var isSelected: Bool + + init(isSeparatorLineHidden: Bool = false, isSelected: Bool = false) { + self.isSelected = isSelected + super.init(isSeparatorLineHidden: isSeparatorLineHidden) + } + } } extension Item: Equatable { @@ -106,6 +118,8 @@ extension Item: Equatable { return true case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)): return attributeLeft == attributeRight + case (.reportStatus(let objectIDLeft, _), .reportStatus(let objectIDRight, _)): + return objectIDLeft == objectIDRight default: return false } @@ -139,6 +153,8 @@ extension Item: Hashable { hasher.combine(String(describing: Item.bottomLoader.self)) case .emptyStateHeader(let attribute): hasher.combine(attribute) + case .reportStatus(let objectID, _): + hasher.combine(objectID) } } } diff --git a/Mastodon/Diffiable/Section/ReportSection.swift b/Mastodon/Diffiable/Section/ReportSection.swift new file mode 100644 index 000000000..6faaae6c2 --- /dev/null +++ b/Mastodon/Diffiable/Section/ReportSection.swift @@ -0,0 +1,66 @@ +// +// ReportSection.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import AVKit +import os.log + +enum ReportSection: Equatable, Hashable { + case main +} + +extension ReportSection { + static func tableViewDiffableDataSource( + for tableView: UITableView, + dependency: ReportViewController, + managedObjectContext: NSManagedObjectContext, + timestampUpdatePublisher: AnyPublisher + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) {[ + weak dependency + ] tableView, indexPath, item -> UITableViewCell? in + guard let dependency = dependency else { return UITableViewCell() } + + switch item { + case .reportStatus(let objectID, let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportedStatusTableViewCell.self), for: indexPath) as! ReportedStatusTableViewCell + cell.dependency = dependency + let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value + let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" + managedObjectContext.performAndWait { [weak dependency] in + guard let dependency = dependency else { return } + let status = managedObjectContext.object(with: objectID) as! Status + StatusSection.configure( + cell: cell, + dependency: dependency, + readableLayoutFrame: tableView.readableContentGuide.layoutFrame, + timestampUpdatePublisher: timestampUpdatePublisher, + status: status, + requestUserID: requestUserID, + statusItemAttribute: attribute + ) + } + + // defalut to select the report status + if attribute.isSelected { + tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) + } else { + tableView.deselectRow(at: indexPath, animated: false) + } + + return cell + default: + return nil + } + } + } +} diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 4f09142a7..b897de47f 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -125,6 +125,8 @@ extension StatusSection { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineHeaderTableViewCell.self), for: indexPath) as! TimelineHeaderTableViewCell StatusSection.configureEmptyStateHeader(cell: cell, attribute: attribute) return cell + case .reportStatus: + return UITableViewCell() } } } @@ -147,7 +149,8 @@ extension StatusSection { .receive(on: DispatchQueue.main) .sink { _ in // do nothing - } receiveValue: { change in + } receiveValue: { [weak cell] change in + guard let cell = cell else { return } guard case .update(let object) = change.changeType, let newStatus = object as? Status else { return } StatusSection.configureHeader(cell: cell, status: newStatus) @@ -219,7 +222,8 @@ extension StatusSection { } else { meta.blurhashImagePublisher() .receive(on: DispatchQueue.main) - .sink { image in + .sink { [weak cell] image in + guard let cell = cell else { return } blurhashOverlayImageView.image = image image?.pngData().flatMap { blurhashImageCache.setObject($0 as NSData, forKey: blurhashImageDataKey) @@ -244,7 +248,8 @@ extension StatusSection { statusItemAttribute.isRevealing ) .receive(on: DispatchQueue.main) - .sink { isImageLoaded, isMediaRevealing in + .sink { [weak cell] isImageLoaded, isMediaRevealing in + guard let cell = cell else { return } guard isImageLoaded else { blurhashOverlayImageView.alpha = 1 blurhashOverlayImageView.isHidden = false @@ -299,6 +304,9 @@ extension StatusSection { case is NotificationStatusTableViewCell: let notificationTableViewCell = cell as! NotificationStatusTableViewCell parent = notificationTableViewCell.delegate?.parent() + case is ReportedStatusTableViewCell: + let reportTableViewCell = cell as! ReportedStatusTableViewCell + parent = reportTableViewCell.dependency default: parent = nil assertionFailure("unknown cell") @@ -350,7 +358,8 @@ extension StatusSection { .receive(on: DispatchQueue.main) .sink { _ in // do nothing - } receiveValue: { [weak dependency] change in + } receiveValue: { [weak dependency, weak cell] change in + guard let cell = cell else { return } guard let dependency = dependency else { return } guard case .update(let object) = change.changeType, let status = object as? Status else { return } @@ -377,7 +386,8 @@ extension StatusSection { ManagedObjectObserver.observe(object: poll) .sink { _ in // do nothing - } receiveValue: { change in + } receiveValue: { [weak cell] change in + guard let cell = cell else { return } guard case .update(let object) = change.changeType, let newPoll = object as? Poll else { return } StatusSection.configurePoll( @@ -392,7 +402,12 @@ extension StatusSection { } // toolbar - StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID) + StatusSection.configureActionToolBar( + cell: cell, + dependency: dependency, + status: status, + requestUserID: requestUserID + ) // separator line if let statusTableViewCell = cell as? StatusTableViewCell { @@ -403,7 +418,8 @@ extension StatusSection { let createdAt = (status.reblog ?? status).createdAt cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow timestampUpdatePublisher - .sink { _ in + .sink { [weak cell] _ in + guard let cell = cell else { return } cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow } .store(in: &cell.disposeBag) @@ -413,10 +429,17 @@ extension StatusSection { .receive(on: DispatchQueue.main) .sink { _ in // do nothing - } receiveValue: { change in + } receiveValue: { [weak dependency, weak cell] change in + guard let cell = cell else { return } + guard let dependency = dependency else { return } guard case .update(let object) = change.changeType, let status = object as? Status else { return } - StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID) + StatusSection.configureActionToolBar( + cell: cell, + dependency: dependency, + status: status, + requestUserID: requestUserID + ) os_log("%{public}s[%{public}ld], %{public}s: reblog count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.reblogsCount.intValue) os_log("%{public}s[%{public}ld], %{public}s: like count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.favouritesCount.intValue) @@ -571,6 +594,7 @@ extension StatusSection { static func configureActionToolBar( cell: StatusCell, + dependency: NeedsDependency, status: Status, requestUserID: String ) { @@ -598,6 +622,8 @@ extension StatusSection { }() cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal) cell.statusView.actionToolbarContainer.isFavoriteButtonHighlight = isLike + + self.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status) } static func configurePoll( @@ -724,4 +750,39 @@ extension StatusSection { guard let number = number, number > 0 else { return "" } return String(number) } + + private static func setupStatusMoreButtonMenu( + cell: StatusCell, + dependency: NeedsDependency, + status: Status) { + + cell.statusView.actionToolbarContainer.moreButton.menu = nil + + guard let authenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let author = (status.reblog ?? status).author + guard authenticationBox.userID != author.id else { + return + } + var children: [UIMenuElement] = [] + let name = author.displayNameWithFallback + let reportAction = UIAction(title: L10n.Common.Controls.Actions.reportUser(name), image: UIImage(systemName: "exclamationmark.bubble"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { + [weak dependency] _ in + guard let dependency = dependency else { return } + let viewModel = ReportViewModel( + context: dependency.context, + domain: authenticationBox.domain, + user: status.author, + status: status) + dependency.coordinator.present( + scene: .report(viewModel: viewModel), + from: nil, + transition: .modal(animated: true, completion: nil) + ) + } + children.append(reportAction) + cell.statusView.actionToolbarContainer.moreButton.menu = UIMenu(title: "", options: [], children: children) + cell.statusView.actionToolbarContainer.moreButton.showsMenuAsPrimaryAction = true + } } diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 7f37f671b..043360043 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -48,6 +48,7 @@ internal enum Asset { internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background") + internal static let systemElevatedBackground = ColorAsset(name: "Colors/Background/system.elevated.background") internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background") internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background") internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Colors/Background/tertiary.system.grouped.background") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 6d7af089d..8dd327048 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -84,6 +84,10 @@ internal enum L10n { internal static let preview = L10n.tr("Localizable", "Common.Controls.Actions.Preview") /// Remove internal static let remove = L10n.tr("Localizable", "Common.Controls.Actions.Remove") + /// Report %@ + internal static func reportUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Actions.ReportUser", String(describing: p1)) + } /// Save internal static let save = L10n.tr("Localizable", "Common.Controls.Actions.Save") /// Save photo @@ -100,6 +104,8 @@ internal enum L10n { internal static let signIn = L10n.tr("Localizable", "Common.Controls.Actions.SignIn") /// Sign Up internal static let signUp = L10n.tr("Localizable", "Common.Controls.Actions.SignUp") + /// Skip + internal static let skip = L10n.tr("Localizable", "Common.Controls.Actions.Skip") /// Take photo internal static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto") /// Try Again @@ -531,6 +537,26 @@ internal enum L10n { } } } + internal enum Report { + /// Are there any other posts you’d like to add to the report? + internal static let content1 = L10n.tr("Localizable", "Scene.Report.Content1") + /// Is there anything the moderators should know about this report? + internal static let content2 = L10n.tr("Localizable", "Scene.Report.Content2") + /// Send Report + internal static let send = L10n.tr("Localizable", "Scene.Report.Send") + /// Send without comment + internal static let skipToSend = L10n.tr("Localizable", "Scene.Report.SkipToSend") + /// Step 1 of 2 + internal static let step1 = L10n.tr("Localizable", "Scene.Report.Step1") + /// Step 2 of 2 + internal static let step2 = L10n.tr("Localizable", "Scene.Report.Step2") + /// Type or paste additional comments + internal static let textPlaceholder = L10n.tr("Localizable", "Scene.Report.TextPlaceholder") + /// Report %@ + internal static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Report.Title", String(describing: p1)) + } + } internal enum Search { internal enum Recommend { /// See All diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 2e6102227..03f84216a 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -498,6 +498,34 @@ extension StatusProviderFacade { .store(in: &dependency.context.disposeBag) } + static func responseToStatusContentWarningRevealAction(dependency: ReportViewController, cell: UITableViewCell) { + let status = Future { promise in + guard let diffableDataSource = dependency.viewModel.diffableDataSource, + let indexPath = dependency.tableView.indexPath(for: cell), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + let managedObjectContext = dependency.viewModel.statusFetchedResultsController + .fetchedResultsController + .managedObjectContext + + switch item { + case .reportStatus(let objectID, _): + managedObjectContext.perform { + let status = managedObjectContext.object(with: objectID) as! Status + promise(.success(status)) + } + default: + promise(.success(nil)) + } + } + + _responseToStatusContentWarningRevealAction( + dependency: dependency, + status: status + ) + } } extension StatusProviderFacade { diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index b64bfe79d..4e8227f69 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -219,6 +219,24 @@ extension UserProviderFacade { children.append(shareAction) } + let reportAction = UIAction(title: L10n.Common.Controls.Actions.reportUser(name), image: UIImage(systemName: "exclamationmark.bubble"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + guard let provider = provider else { return } + guard let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let viewModel = ReportViewModel( + context: provider.context, + domain: authenticationBox.domain, + user: mastodonUser, + status: nil) + provider.coordinator.present( + scene: .report(viewModel: viewModel), + from: provider, + transition: .modal(animated: true, completion: nil) + ) + } + children.append(reportAction) + return UIMenu(title: "", options: [], children: children) } diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json index 55f84c267..5e7067405 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xFE", - "green" : "0xFF", - "red" : "0xFE" + "blue" : "254", + "green" : "255", + "red" : "254" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2E", - "green" : "0x2C", - "red" : "0x2C" + "blue" : "46", + "green" : "44", + "red" : "44" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json index bd6f07f25..82abbf254 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xE8", - "green" : "0xE1", - "red" : "0xD9" + "blue" : "232", + "green" : "225", + "red" : "217" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2E", - "green" : "0x2C", - "red" : "0x2C" + "blue" : "46", + "green" : "44", + "red" : "44" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.elevated.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.elevated.background.colorset/Contents.json new file mode 100644 index 000000000..e13fb4690 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.elevated.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x1E", + "green" : "0x1C", + "red" : "0x1C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index ce7a3a2fe..da8bca1c9 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -26,6 +26,7 @@ Please check your internet connection."; "Common.Controls.Actions.OpenInSafari" = "Open in Safari"; "Common.Controls.Actions.Preview" = "Preview"; "Common.Controls.Actions.Remove" = "Remove"; +"Common.Controls.Actions.ReportUser" = "Report %@"; "Common.Controls.Actions.Save" = "Save"; "Common.Controls.Actions.SavePhoto" = "Save photo"; "Common.Controls.Actions.SeeMore" = "See More"; @@ -33,6 +34,7 @@ Please check your internet connection."; "Common.Controls.Actions.ShareUser" = "Share %@"; "Common.Controls.Actions.SignIn" = "Sign In"; "Common.Controls.Actions.SignUp" = "Sign Up"; +"Common.Controls.Actions.Skip" = "Skip"; "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Actions.TryAgain" = "Try Again"; "Common.Controls.Firendship.Block" = "Block"; @@ -171,6 +173,14 @@ tap the link to confirm your account."; "Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken."; "Scene.Register.Input.Username.Placeholder" = "username"; "Scene.Register.Title" = "Tell us about you."; +"Scene.Report.Content1" = "Are there any other posts you’d like to add to the report?"; +"Scene.Report.Content2" = "Is there anything the moderators should know about this report?"; +"Scene.Report.Send" = "Send Report"; +"Scene.Report.SkipToSend" = "Send without comment"; +"Scene.Report.Step1" = "Step 1 of 2"; +"Scene.Report.Step2" = "Step 2 of 2"; +"Scene.Report.TextPlaceholder" = "Type or paste additional comments"; +"Scene.Report.Title" = "Report %@"; "Scene.Search.Recommend.Accounts.Description" = "Except for Sam, you will not like his account."; "Scene.Search.Recommend.Accounts.Follow" = "Follow"; "Scene.Search.Recommend.Accounts.Title" = "Accounts you might like"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index e338aa09f..ff83e576e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -336,7 +336,12 @@ extension HomeTimelineViewController { } @objc private func showSettings(_ sender: UIAction) { - coordinator.present(scene: .settings, from: self, transition: .modal(animated: true, completion: nil)) + let viewModel = SettingsViewModel(context: context) + coordinator.present( + scene: .settings(viewModel: viewModel), + from: self, + transition: .modal(animated: true, completion: nil) + ) } } #endif diff --git a/Mastodon/Scene/Report/ReportFooterView.swift b/Mastodon/Scene/Report/ReportFooterView.swift new file mode 100644 index 000000000..a64a556d3 --- /dev/null +++ b/Mastodon/Scene/Report/ReportFooterView.swift @@ -0,0 +1,108 @@ +// +// ReportFooterView.swift +// Mastodon +// +// Created by ihugo on 2021/4/22. +// + +import UIKit + +final class ReportFooterView: UIView { + enum Step: Int { + case one + case two + } + + lazy var stackview: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.spacing = 8 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var nextStepButton: PrimaryActionButton = { + let button = PrimaryActionButton() + button.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + lazy var skipButton: UIButton = { + let button = UIButton(type: .system) + button.tintColor = Asset.Colors.brandBlue.color + button.setTitle(L10n.Common.Controls.Actions.skip, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + var step: Step = .one { + didSet { + switch step { + case .one: + nextStepButton.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) + skipButton.setTitle(L10n.Common.Controls.Actions.skip, for: .normal) + case .two: + nextStepButton.setTitle(L10n.Scene.Report.send, for: .normal) + skipButton.setTitle(L10n.Scene.Report.skipToSend, for: .normal) + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = Asset.Colors.Background.systemElevatedBackground.color + + stackview.addArrangedSubview(nextStepButton) + stackview.addArrangedSubview(skipButton) + addSubview(stackview) + + NSLayoutConstraint.activate([ + stackview.topAnchor.constraint( + equalTo: self.topAnchor, + constant: ReportView.continuTopMargin + ), + stackview.leadingAnchor.constraint( + equalTo: self.readableContentGuide.leadingAnchor, + constant: ReportView.horizontalMargin + ), + stackview.bottomAnchor.constraint( + equalTo: self.safeAreaLayoutGuide.bottomAnchor, + constant: -1 * ReportView.skipBottomMargin + ), + stackview.trailingAnchor.constraint( + equalTo: self.readableContentGuide.trailingAnchor, + constant: -1 * ReportView.horizontalMargin + ), + nextStepButton.heightAnchor.constraint( + equalToConstant: ReportView.buttonHeight + ), + skipButton.heightAnchor.constraint( + equalTo: nextStepButton.heightAnchor + ) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ReportFooterView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview(width: 375) { () -> UIView in + return ReportFooterView(frame: CGRect(origin: .zero, size: CGSize(width: 375, height: 164))) + } + .previewLayout(.fixed(width: 375, height: 164)) + } + } + +} + +#endif diff --git a/Mastodon/Scene/Report/ReportHeaderView.swift b/Mastodon/Scene/Report/ReportHeaderView.swift new file mode 100644 index 000000000..8a6d957c8 --- /dev/null +++ b/Mastodon/Scene/Report/ReportHeaderView.swift @@ -0,0 +1,115 @@ +// +// ReportView.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import UIKit + +struct ReportView { + static var horizontalMargin: CGFloat { return 12 } + static var verticalMargin: CGFloat { return 22 } + static var buttonHeight: CGFloat { return 46 } + static var skipBottomMargin: CGFloat { return 8 } + static var continuTopMargin: CGFloat { return 22 } +} + +final class ReportHeaderView: UIView { + enum Step: Int { + case one + case two + } + + lazy var titleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = UIFontMetrics(forTextStyle: .subheadline) + .scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + label.numberOfLines = 0 + return label + }() + + lazy var contentLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.primary.color + label.font = UIFontMetrics(forTextStyle: .title3) + .scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) + label.numberOfLines = 0 + return label + }() + + lazy var stackview: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .leading + view.spacing = 2 + return view + }() + + var step: Step = .one { + didSet { + switch step { + case .one: + titleLabel.text = L10n.Scene.Report.step1 + contentLabel.text = L10n.Scene.Report.content1 + case .two: + titleLabel.text = L10n.Scene.Report.step2 + contentLabel.text = L10n.Scene.Report.content2 + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = Asset.Colors.Background.systemElevatedBackground.color + stackview.addArrangedSubview(titleLabel) + stackview.addArrangedSubview(contentLabel) + addSubview(stackview) + + stackview.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackview.topAnchor.constraint( + equalTo: self.topAnchor, + constant: ReportView.verticalMargin + ), + stackview.leadingAnchor.constraint( + equalTo: self.readableContentGuide.leadingAnchor, + constant: ReportView.horizontalMargin + ), + self.bottomAnchor.constraint( + equalTo: stackview.bottomAnchor, + constant: ReportView.verticalMargin + ), + self.readableContentGuide.trailingAnchor.constraint( + equalTo: stackview.trailingAnchor, + constant: ReportView.horizontalMargin + ) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ReportHeaderView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview { () -> UIView in + let view = ReportHeaderView() + view.step = .one + view.contentLabel.preferredMaxLayoutWidth = 335 + return view + } + .previewLayout(.fixed(width: 375, height: 110)) + } + } + +} + +#endif diff --git a/Mastodon/Scene/Report/ReportViewController.swift b/Mastodon/Scene/Report/ReportViewController.swift new file mode 100644 index 000000000..b0c6ddcc9 --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewController.swift @@ -0,0 +1,343 @@ +// +// ReportViewController.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import AVKit +import Combine +import CoreData +import CoreDataStack +import os.log +import UIKit +import TwitterTextEditor +import MastodonSDK + +class ReportViewController: UIViewController, NeedsDependency { + static let kAnimationDuration: TimeInterval = 0.33 + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var viewModel: ReportViewModel! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() + let didToggleSelected = PassthroughSubject() + let comment = CurrentValueSubject(nil) + let step1Continue = PassthroughSubject() + let step1Skip = PassthroughSubject() + let step2Continue = PassthroughSubject() + let step2Skip = PassthroughSubject() + let cancel = PassthroughSubject() + + // MAKK: - UI + lazy var header: ReportHeaderView = { + let view = ReportHeaderView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var footer: ReportFooterView = { + let view = ReportFooterView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var contentView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.setContentHuggingPriority(.defaultLow, for: .vertical) + view.backgroundColor = Asset.Colors.Background.systemElevatedBackground.color + return view + }() + + lazy var stackview: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.register(ReportedStatusTableViewCell.self, forCellReuseIdentifier: String(describing: ReportedStatusTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.delegate = self + tableView.prefetchDataSource = self + tableView.allowsMultipleSelection = true + return tableView + }() + + lazy var textView: UITextView = { + let textView = UITextView() + textView.font = .preferredFont(forTextStyle: .body) + textView.isScrollEnabled = false + textView.placeholder = L10n.Scene.Report.textPlaceholder + textView.backgroundColor = .clear + textView.delegate = self + textView.isScrollEnabled = true + textView.keyboardDismissMode = .onDrag + return textView + }() + + lazy var bottomSpacing: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + var bottomConstraint: NSLayoutConstraint! + + override func viewDidLoad() { + super.viewDidLoad() + + setupView() + + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self + ) + + bindViewModel() + bindActions() + } + + // MAKR: - Private methods + private func setupView() { + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + setupNavigation() + + stackview.addArrangedSubview(header) + stackview.addArrangedSubview(contentView) + stackview.addArrangedSubview(footer) + stackview.addArrangedSubview(bottomSpacing) + + contentView.addSubview(tableView) + + view.addSubview(stackview) + NSLayoutConstraint.activate([ + stackview.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stackview.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stackview.bottomAnchor.constraint(equalTo: view.bottomAnchor), + stackview.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.topAnchor.constraint(equalTo: contentView.topAnchor), + tableView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + tableView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + tableView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + + self.bottomConstraint = bottomSpacing.heightAnchor.constraint(equalToConstant: 0) + bottomConstraint.isActive = true + + header.step = .one + } + + private func bindActions() { + footer.nextStepButton.addTarget(self, action: #selector(continueButtonDidClick), for: .touchUpInside) + footer.skipButton.addTarget(self, action: #selector(skipButtonDidClick), for: .touchUpInside) + } + + private func bindViewModel() { + let input = ReportViewModel.Input( + didToggleSelected: didToggleSelected.eraseToAnyPublisher(), + comment: comment.eraseToAnyPublisher(), + step1Continue: step1Continue.eraseToAnyPublisher(), + step1Skip: step1Skip.eraseToAnyPublisher(), + step2Continue: step2Continue.eraseToAnyPublisher(), + step2Skip: step2Skip.eraseToAnyPublisher(), + cancel: cancel.eraseToAnyPublisher() + ) + let output = viewModel.transform(input: input) + output?.currentStep + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] (step) in + guard step == .two else { return } + guard let self = self else { return } + + self.header.step = .two + self.footer.step = .two + self.switchToStep2Content() + }) + .store(in: &disposeBag) + + output?.continueEnableSubject + .receive(on: DispatchQueue.main) + .filter { [weak self] _ in + guard let step = self?.viewModel.currentStep.value, step == .one else { return false } + return true + } + .assign(to: \.nextStepButton.isEnabled, on: footer) + .store(in: &disposeBag) + + output?.sendEnableSubject + .receive(on: DispatchQueue.main) + .filter { [weak self] _ in + guard let step = self?.viewModel.currentStep.value, step == .two else { return false } + return true + } + .assign(to: \.nextStepButton.isEnabled, on: footer) + .store(in: &disposeBag) + + output?.reportResult + .print() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in + }, receiveValue: { [weak self] data in + let (success, error) = data + if success { + self?.dismiss(animated: true, completion: nil) + } else if let error = error { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fail to file a report : %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + + let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert) + let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) + alertController.addAction(okAction) + self?.coordinator.present( + scene: .alertController(alertController: alertController), + from: nil, + transition: .alertController(animated: true, completion: nil) + ) + } + }) + .store(in: &disposeBag) + + Publishers.CombineLatest( + KeyboardResponderService.shared.state.eraseToAnyPublisher(), + KeyboardResponderService.shared.endFrame.eraseToAnyPublisher() + ) + .sink(receiveValue: { [weak self] state, endFrame in + guard let self = self else { return } + + guard state == .dock else { + self.bottomConstraint.constant = 0.0 + return + } + + let contentFrame = self.view.convert(self.view.frame, to: nil) + let padding = contentFrame.maxY - endFrame.minY + guard padding > 0 else { + self.bottomConstraint.constant = 0.0 + return + } + + self.bottomConstraint.constant = padding + }) + .store(in: &disposeBag) + } + + private func setupNavigation() { + navigationItem.rightBarButtonItem + = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.cancel, + target: self, + action: #selector(doneButtonDidClick)) + navigationItem.rightBarButtonItem?.tintColor = Asset.Colors.Label.highlight.color + + // fetch old mastodon user + let beReportedUser: MastodonUser? = { + guard let domain = context.authenticationService.activeMastodonAuthenticationBox.value?.domain else { + return nil + } + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: viewModel.user.id) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + navigationItem.title = L10n.Scene.Report.title( + beReportedUser?.displayNameWithFallback ?? "" + ) + } + + private func switchToStep2Content() { + self.contentView.addSubview(self.textView) + self.textView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.textView.topAnchor.constraint(equalTo: self.contentView.topAnchor), + self.textView.leadingAnchor.constraint( + equalTo: self.contentView.readableContentGuide.leadingAnchor, + constant: ReportView.horizontalMargin + ), + self.textView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), + self.contentView.trailingAnchor.constraint( + equalTo: self.textView.trailingAnchor, + constant: ReportView.horizontalMargin + ), + ]) + self.textView.layoutIfNeeded() + + UIView.transition( + with: contentView, + duration: ReportViewController.kAnimationDuration, + options: UIView.AnimationOptions.transitionCrossDissolve) { + [weak self] in + guard let self = self else { return } + + self.contentView.addSubview(self.textView) + self.tableView.isHidden = true + } completion: { (_) in + } + } + + // Mark: - Actions + @objc func doneButtonDidClick() { + dismiss(animated: true, completion: nil) + } + + @objc func continueButtonDidClick() { + if viewModel.currentStep.value == .one { + step1Continue.send() + } else { + step2Continue.send() + } + } + + @objc func skipButtonDidClick() { + if viewModel.currentStep.value == .one { + step1Skip.send() + } else { + step2Skip.send() + } + } +} + +// MARK: - UITableViewDelegate +extension ReportViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return + } + didToggleSelected.send(item) + } + + func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return + } + didToggleSelected.send(item) + } +} + +// MARK: - UITableViewDataSourcePrefetching +extension ReportViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + viewModel.prefetchData(prefetchRowsAt: indexPaths) + } +} + +// MARK: - UITextViewDelegate +extension ReportViewController: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + self.comment.send(textView.text) + } +} diff --git a/Mastodon/Scene/Report/ReportViewModel+Data.swift b/Mastodon/Scene/Report/ReportViewModel+Data.swift new file mode 100644 index 000000000..df95cb002 --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewModel+Data.swift @@ -0,0 +1,138 @@ +// +// ReportViewModel+Data.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import os.log + +extension ReportViewModel { + func requestRecentStatus( + domain: String, + accountId: String, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) { + context.apiService.userTimeline( + domain: domain, + accountID: accountId, + excludeReblogs: true, + authorizationBox: authorizationBox + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] 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) + guard let self = self else { return } + guard let reportStatusId = self.status?.id else { return } + var statusIDs = self.statusFetchedResultsController.statusIDs.value + guard statusIDs.contains(reportStatusId) else { return } + + statusIDs.append(reportStatusId) + self.statusFetchedResultsController.statusIDs.value = statusIDs + case .finished: + break + } + } receiveValue: { [weak self] response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + + var statusIDs = response.value.map { $0.id } + if let reportStatusId = self.status?.id, !statusIDs.contains(reportStatusId) { + statusIDs.append(reportStatusId) + } + + self.statusFetchedResultsController.statusIDs.value = statusIDs + } + .store(in: &disposeBag) + } + + func fetchStatus() { + let managedObjectContext = self.statusFetchedResultsController.fetchedResultsController.managedObjectContext + statusFetchedResultsController.objectIDs.eraseToAnyPublisher() + .receive(on: DispatchQueue.main) + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { [weak self] objectIDs 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) + } + + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.ReportStatusAttribute] = [:] + let oldSnapshot = diffableDataSource.snapshot() + for item in oldSnapshot.itemIdentifiers { + guard case let .reportStatus(objectID, attribute) = item else { continue } + oldSnapshotAttributeDict[objectID] = attribute + } + + for objectID in objectIDs { + let attribute = oldSnapshotAttributeDict[objectID] ?? Item.ReportStatusAttribute() + let item = Item.reportStatus(objectID: objectID, attribute: attribute) + items.append(item) + + guard let status = managedObjectContext.object(with: objectID) as? Status else { + continue + } + if status.id == self.status?.id { + attribute.isSelected = true + self.append(statusID: status.id) + self.continueEnableSubject.send(true) + } + } + snapshot.appendItems(items, toSection: .main) + } + .store(in: &disposeBag) + } + + func prefetchData(prefetchRowsAt indexPaths: [IndexPath]) { + guard let diffableDataSource = diffableDataSource else { return } + + // prefetch reply status + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let domain = activeMastodonAuthenticationBox.domain + + var statusObjectIDs: [NSManagedObjectID] = [] + for indexPath in indexPaths { + let item = diffableDataSource.itemIdentifier(for: indexPath) + switch item { + case .reportStatus(let objectID, _): + statusObjectIDs.append(objectID) + default: + continue + } + } + + let backgroundManagedObjectContext = context.backgroundManagedObjectContext + backgroundManagedObjectContext.perform { [weak self] in + guard let self = self else { return } + for objectID in statusObjectIDs { + let status = backgroundManagedObjectContext.object(with: objectID) as! Status + guard let replyToID = status.inReplyToID, status.replyTo == nil else { + // skip + continue + } + self.context.statusPrefetchingService.prefetchReplyTo( + domain: domain, + statusObjectID: status.objectID, + statusID: status.id, + replyToStatusID: replyToID, + authorizationBox: activeMastodonAuthenticationBox + ) + } + } + } +} diff --git a/Mastodon/Scene/Report/ReportViewModel+Diffable.swift b/Mastodon/Scene/Report/ReportViewModel+Diffable.swift new file mode 100644 index 000000000..73d6ffa0d --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewModel+Diffable.swift @@ -0,0 +1,35 @@ +// +// ReportViewModel+Diffable.swift +// Mastodon +// +// Created by ihugo on 2021/4/19. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack + +extension ReportViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + dependency: ReportViewController + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = ReportSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher + ) + + // set empty section to make update animation top-to-bottom style + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + } +} diff --git a/Mastodon/Scene/Report/ReportViewModel.swift b/Mastodon/Scene/Report/ReportViewModel.swift new file mode 100644 index 000000000..8631963c5 --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewModel.swift @@ -0,0 +1,215 @@ +// +// ReportViewModel.swift +// Mastodon +// +// Created by ihugo on 2021/4/19. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import os.log + +class ReportViewModel: NSObject { + typealias FileReportQuery = Mastodon.API.Reports.FileReportQuery + + enum Step: Int { + case one + case two + } + + // confirm set only once + weak var context: AppContext! { willSet { precondition(context == nil) } } + var user: MastodonUser + var status: Status? + + var statusIDs = [Mastodon.Entity.Status.ID]() + var comment: String? + + var reportQuery: FileReportQuery + var disposeBag = Set() + let currentStep = CurrentValueSubject(.one) + let statusFetchedResultsController: StatusFetchedResultsController + var diffableDataSource: UITableViewDiffableDataSource? + let continueEnableSubject = CurrentValueSubject(false) + let sendEnableSubject = CurrentValueSubject(false) + + struct Input { + let didToggleSelected: AnyPublisher + let comment: AnyPublisher + let step1Continue: AnyPublisher + let step1Skip: AnyPublisher + let step2Continue: AnyPublisher + let step2Skip: AnyPublisher + let cancel: AnyPublisher + } + + struct Output { + let currentStep: AnyPublisher + let continueEnableSubject: AnyPublisher + let sendEnableSubject: AnyPublisher + let reportResult: AnyPublisher<(Bool, Error?), Never> + } + + init(context: AppContext, + domain: String, + user: MastodonUser, + status: Status? + ) { + self.context = context + self.user = user + self.status = status + self.statusFetchedResultsController = StatusFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: domain, + additionalTweetPredicate: Status.notDeleted() + ) + + self.reportQuery = FileReportQuery( + accountID: user.id, + statusIDs: [], + comment: nil, + forward: nil + ) + super.init() + } + + func transform(input: Input?) -> Output? { + guard let input = input else { return nil } + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return nil + } + let domain = activeMastodonAuthenticationBox.domain + + // data binding + bindData(input: input) + + // step1 and step2 binding + bindForStep1(input: input) + let reportResult = bindForStep2( + input: input, + domain: domain, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + + requestRecentStatus( + domain: domain, + accountId: self.user.id, + authorizationBox: activeMastodonAuthenticationBox + ) + + fetchStatus() + + return Output( + currentStep: currentStep.eraseToAnyPublisher(), + continueEnableSubject: continueEnableSubject.eraseToAnyPublisher(), + sendEnableSubject: sendEnableSubject.eraseToAnyPublisher(), + reportResult: reportResult + ) + } + + // MARK: - Private methods + func bindData(input: Input) { + input.didToggleSelected.sink { [weak self] (item) in + guard let self = self else { return } + guard case let .reportStatus(objectID, attribute) = item else { return } + let managedObjectContext = self.statusFetchedResultsController.fetchedResultsController.managedObjectContext + guard let status = managedObjectContext.object(with: objectID) as? Status else { + return + } + + attribute.isSelected = !attribute.isSelected + if attribute.isSelected { + self.append(statusID: status.id) + } else { + self.remove(statusID: status.id) + } + + let continueEnable = self.statusIDs.count > 0 + self.continueEnableSubject.send(continueEnable) + } + .store(in: &disposeBag) + + input.comment.sink { [weak self] (comment) in + guard let self = self else { return } + + self.comment = comment + + let sendEnable = (comment?.length ?? 0) > 0 + self.sendEnableSubject.send(sendEnable) + } + .store(in: &disposeBag) + } + + func bindForStep1(input: Input) { + let skip = input.step1Skip.map { [weak self] value -> Void in + guard let self = self else { return value } + self.reportQuery.statusIDs?.removeAll() + return value + } + + let step1Continue = input.step1Continue.map { [weak self] value -> Void in + guard let self = self else { return value } + self.reportQuery.statusIDs = self.statusIDs + return value + } + + Publishers.Merge(skip, step1Continue) + .sink { [weak self] _ in + self?.currentStep.value = .two + self?.sendEnableSubject.send(false) + } + .store(in: &disposeBag) + } + + func bindForStep2(input: Input, domain: String, activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> AnyPublisher<(Bool, Error?), Never> { + let skip = input.step2Skip.map { [weak self] value -> Void in + guard let self = self else { return value } + self.reportQuery.comment = nil + return value + } + + let step2Continue = input.step2Continue.map { [weak self] value -> Void in + guard let self = self else { return value } + self.reportQuery.comment = self.comment + return value + } + + return Publishers.Merge(skip, step2Continue) + .flatMap { [weak self] (_) -> AnyPublisher<(Bool, Error?), Never> in + guard let self = self else { + return Empty(completeImmediately: true).eraseToAnyPublisher() + } + + return self.context.apiService.report( + domain: domain, + query: self.reportQuery, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .map({ (content) -> (Bool, Error?) in + return (true, nil) + }) + .eraseToAnyPublisher() + .tryCatch({ (error) -> AnyPublisher<(Bool, Error?), Never> in + return Just((false, error)).eraseToAnyPublisher() + }) + // to covert to AnyPublisher<(Bool, Error?), Never> + .replaceError(with: (false, nil)) + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func append(statusID: Mastodon.Entity.Status.ID) { + guard self.statusIDs.contains(statusID) != true else { return } + self.statusIDs.append(statusID) + } + + func remove(statusID: String) { + guard let index = self.statusIDs.firstIndex(of: statusID) else { return } + self.statusIDs.remove(at: index) + } +} diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift new file mode 100644 index 000000000..b1d0af6b0 --- /dev/null +++ b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift @@ -0,0 +1,217 @@ +// +// ReportedStatusTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import os.log +import UIKit +import AVKit +import Combine +import CoreData +import CoreDataStack +import ActiveLabel + +final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { + + static let bottomPaddingHeight: CGFloat = 10 + + weak var dependency: ReportViewController? + var disposeBag = Set() + var pollCountdownSubscription: AnyCancellable? + var observations = Set() + + let statusView = StatusView() + let separatorLine = UIView.separatorLine + + let checkbox: UIImageView = { + let imageView = UIImageView() + imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) + imageView.tintColor = Asset.Colors.Label.secondary.color + imageView.contentMode = .scaleAspectFill + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! + + var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! + + override func prepareForReuse() { + super.prepareForReuse() + statusView.updateContentWarningDisplay(isHidden: true, animated: false) + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true + statusView.pollTableView.dataSource = nil + statusView.playerContainerView.reset() + statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = true + statusView.playerContainerView.isHidden = true + disposeBag.removeAll() + observations.removeAll() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + override func layoutSubviews() { + super.layoutSubviews() + + // precondition: app is active + guard UIApplication.shared.applicationState == .active else { return } + DispatchQueue.main.async { + self.statusView.drawContentWarningImageView() + } + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + if highlighted { + checkbox.image = UIImage(systemName: "checkmark.circle.fill") + checkbox.tintColor = Asset.Colors.Label.highlight.color + } else if !isSelected { + checkbox.image = UIImage(systemName: "circle") + checkbox.tintColor = Asset.Colors.Label.secondary.color + } + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + if isSelected { + checkbox.image = UIImage(systemName: "checkmark.circle.fill") + } else { + checkbox.image = UIImage(systemName: "circle") + } + checkbox.tintColor = Asset.Colors.Label.secondary.color + } +} + +extension ReportedStatusTableViewCell { + + private func _init() { + backgroundColor = Asset.Colors.Background.systemBackground.color + + checkbox.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(checkbox) + NSLayoutConstraint.activate([ + checkbox.widthAnchor.constraint(equalToConstant: 23), + checkbox.heightAnchor.constraint(equalToConstant: 22), + checkbox.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor, constant: 12), + checkbox.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + ]) + + statusView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(statusView) + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), + statusView.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 20), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 20), + ]) + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) + separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor) + separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor) + NSLayoutConstraint.activate([ + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + resetSeparatorLineLayout() + + selectionStyle = .none + statusView.delegate = self + statusView.statusMosaicImageViewContainer.delegate = self + statusView.actionToolbarContainer.isHidden = true + statusView.contentWarningOverlayView.blurContentImageView.backgroundColor = backgroundColor + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + resetSeparatorLineLayout() + } +} + +extension ReportedStatusTableViewCell { + private func resetSeparatorLineLayout() { + separatorLineToEdgeLeadingLayoutConstraint.isActive = false + separatorLineToEdgeTrailingLayoutConstraint.isActive = false + separatorLineToMarginLeadingLayoutConstraint.isActive = false + separatorLineToMarginTrailingLayoutConstraint.isActive = false + + if traitCollection.userInterfaceIdiom == .phone { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + if traitCollection.horizontalSizeClass == .compact { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + // to margin + NSLayoutConstraint.activate([ + separatorLineToMarginLeadingLayoutConstraint, + separatorLineToMarginTrailingLayoutConstraint, + ]) + } + } + } +} + +extension ReportedStatusTableViewCell: MosaicImageViewContainerDelegate { + func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { + + } + + func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + + guard let dependency = self.dependency else { return } + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: dependency, cell: self) + } +} + +extension ReportedStatusTableViewCell: StatusViewDelegate { + func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) { + } + + func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) { + } + + func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { + guard let dependency = self.dependency else { return } + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: dependency, cell: self) + } + + func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + guard let dependency = self.dependency else { return } + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: dependency, cell: self) + } + + func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + guard let dependency = self.dependency else { return } + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: dependency, cell: self) + } + + func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) { + } + + func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + } +} diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 4615f92ab..156321d89 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -148,7 +148,7 @@ class SettingsViewController: UIViewController, NeedsDependency { } private func setupView() { - view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color setupNavigation() setupTableView() diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index 470617aeb..57371f92b 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -13,10 +13,9 @@ import MastodonSDK import UIKit import os.log -class SettingsViewModel: NSObject, NeedsDependency { +class SettingsViewModel: NSObject { // confirm set only once weak var context: AppContext! { willSet { precondition(context == nil) } } - weak var coordinator: SceneCoordinator! { willSet { precondition(coordinator == nil) } } var dataSource: UITableViewDiffableDataSource! var disposeBag = Set() @@ -87,9 +86,8 @@ class SettingsViewModel: NSObject, NeedsDependency { struct Output { } - init(context: AppContext, coordinator: SceneCoordinator) { + init(context: AppContext) { self.context = context - self.coordinator = coordinator super.init() } diff --git a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift index b5e4c5bde..f095f6f44 100644 --- a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift +++ b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift @@ -5,6 +5,8 @@ // Created by MainasuK Cirno on 2021-4-6. // +import UIKit + final class TimelineHeaderView: UIView { let iconImageView: UIImageView = { diff --git a/Mastodon/Service/APIService/APIService+Report.swift b/Mastodon/Service/APIService/APIService+Report.swift new file mode 100644 index 000000000..3c170c625 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Report.swift @@ -0,0 +1,23 @@ +// +// APIService+Report.swift +// Mastodon +// +// Created by ihugo on 2021/4/19. +// + +import Foundation +import MastodonSDK +import Combine + +extension APIService { + + func report( + domain: String, + query: Mastodon.API.Reports.FileReportQuery, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Reports.fileReport(session: session, domain: domain, query: query, authorization: authorization) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift new file mode 100644 index 000000000..6ba8c3cf5 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift @@ -0,0 +1,91 @@ +// +// File.swift +// +// +// Created by ihugo on 2021/4/19. +// + +import Combine +import Foundation +import enum NIOHTTP1.HTTPResponseStatus + +extension Mastodon.API.Reports { + static func reportsEndpointURL(domain: String) -> URL { + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("reports") + } + + /// File a report + /// + /// Version history: + /// 1.1 - added + /// 2.3.0 - add forward parameter + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/reports/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: fileReportQuery query + /// - authorization: User token + /// - Returns: `AnyPublisher` contains status indicate if report sucessfully. + public static func fileReport( + session: URLSession, + domain: String, + query: Mastodon.API.Reports.FileReportQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: reportsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + guard let response = response as? HTTPURLResponse else { + assertionFailure() + throw NSError() + } + + if response.statusCode == 200 { + return Mastodon.Response.Content( + value: true, + response: response + ) + } else { + let httpResponseStatus = HTTPResponseStatus(statusCode: response.statusCode) + throw Mastodon.API.Error( + httpResponseStatus: httpResponseStatus, + mastodonError: nil + ) + } + } + .eraseToAnyPublisher() + } +} + + +public extension Mastodon.API.Reports { + class FileReportQuery: Codable, PostQuery { + public let accountID: Mastodon.Entity.Account.ID + public var statusIDs: [Mastodon.Entity.Status.ID]? + public var comment: String? + public let forward: Bool? + + enum CodingKeys: String, CodingKey { + case accountID = "account_id" + case statusIDs = "status_ids" + case comment + case forward + } + + public init( + accountID: Mastodon.Entity.Account.ID, + statusIDs: [Mastodon.Entity.Status.ID]?, + comment: String?, + forward: Bool?) { + self.accountID = accountID + self.statusIDs = statusIDs + self.comment = comment + self.forward = forward + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 921cc9ed3..cfaa1736d 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -116,6 +116,7 @@ extension Mastodon.API { public enum Suggestions { } public enum Notifications { } public enum Subscriptions { } + public enum Reports { } } extension Mastodon.API.V2 {