diff --git a/CoreDataStack/Entity/Status.swift b/CoreDataStack/Entity/Status.swift index f40f7863..81ace7a5 100644 --- a/CoreDataStack/Entity/Status.swift +++ b/CoreDataStack/Entity/Status.swift @@ -337,4 +337,8 @@ extension Status { public static func deleted() -> NSPredicate { return NSPredicate(format: "%K != nil", #keyPath(Status.deletedAt)) } + + public static func author(author: MastodonUser) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Status.author), author) + } } diff --git a/Localization/app.json b/Localization/app.json index 0aa62271..18d7a193 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -51,7 +51,8 @@ "preview": "Preview", "share": "Share", "share_user": "Share %s", - "open_in_safari": "Open in Safari" + "open_in_safari": "Open in Safari", + "skip": "Skip" }, "status": { "user_reblogged": "%s reblogged", @@ -329,7 +330,7 @@ }, "favorite": { "title": "Your Favorites" - }, + }, "notification": { "title": { "Everything": "Everything", @@ -341,6 +342,7 @@ "reblog": "rebloged your post", "poll": "Your poll has ended", "mention": "mentioned you" + } }, "thread": { "back_title": "Post", @@ -388,6 +390,17 @@ "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", + "skipToSend": "Send without comment", + "textPlaceHolder": "|Type or paste additional comments" } } } + diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 7b147991..67bd4878 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -133,6 +133,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 */; }; + 5B24BBDC262DB14800A9381B /* ReportViewModel+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD9262DB14800A9381B /* ReportViewModel+Provider.swift */; }; + 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBE1262DB19100A9381B /* APIService+Report.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 */; }; @@ -144,6 +148,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 /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FDA262EA3070043BFF6 /* ReportView.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 */; }; @@ -547,6 +556,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 = ""; }; + 5B24BBD9262DB14800A9381B /* ReportViewModel+Provider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Provider.swift"; sourceTree = ""; }; + 5B24BBE1262DB19100A9381B /* APIService+Report.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Report.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 = ""; }; @@ -558,6 +571,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 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.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 = ""; }; @@ -1114,6 +1132,7 @@ 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, + 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, ); path = Section; sourceTree = ""; @@ -1217,6 +1236,20 @@ name = Frameworks; sourceTree = ""; }; + 5B24BBD6262DB14800A9381B /* Report */ = { + isa = PBXGroup; + children = ( + 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */, + 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */, + 5B24BBD9262DB14800A9381B /* ReportViewModel+Provider.swift */, + 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */, + 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */, + 5BB04FDA262EA3070043BFF6 /* ReportView.swift */, + 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */, + ); + path = Report; + sourceTree = ""; + }; 5B90C455262599800002E742 /* Settings */ = { isa = PBXGroup; children = ( @@ -1429,6 +1462,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 */, @@ -1668,6 +1702,7 @@ DB01409B25C40BB600F9F3CF /* Onboarding */, 2D38F1D325CD463600561493 /* HomeTimeline */, 2D76316325C14BAC00929FB9 /* PublicTimeline */, + 5B24BBD6262DB14800A9381B /* Report */, 0F2021F5261325ED000C64BF /* HashtagTimeline */, DB9D6BEE25E4F5370051B173 /* Search */, 5B90C455262599800002E742 /* Settings */, @@ -2327,10 +2362,12 @@ 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */, DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */, + 5BB04FEF262F0DCB0043BFF6 /* ReportViewModel+Data.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 */, @@ -2439,6 +2476,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 */, @@ -2490,6 +2528,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 */, @@ -2497,6 +2536,7 @@ DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, + 5BB04FDB262EA3070043BFF6 /* ReportView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, @@ -2519,6 +2559,7 @@ 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */, + 5B24BBDC262DB14800A9381B /* ReportViewModel+Provider.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */, 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */, @@ -2594,6 +2635,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 */, @@ -2604,6 +2647,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 c2608fe8..93393d54 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -65,10 +65,10 @@ extension SceneCoordinator { case safari(url: URL) case alertController(alertController: UIAlertController) case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?) - + case settings + case report(userId: String, statusId: String?) #if DEBUG case publicTimeline - case settings #endif var isOnboarding: Bool { @@ -265,15 +265,28 @@ private extension SceneCoordinator { activityViewController.popoverPresentationController?.sourceView = sourceView activityViewController.popoverPresentationController?.barButtonItem = barButtonItem viewController = activityViewController + case .settings: + let _viewController = SettingsViewController() + _viewController.viewModel = SettingsViewModel(context: appContext, coordinator: self) + viewController = _viewController + case .report(let userId, let statusId): + guard let authenticationBox = appContext.authenticationService.activeMastodonAuthenticationBox.value else { + return nil + } + let _viewController = ReportViewController() + _viewController.viewModel = ReportViewModel( + context: appContext, + coordinator: self, + domain: authenticationBox.domain, + userId: userId, + statusId: statusId + ) + 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/Section/ReportSection.swift b/Mastodon/Diffiable/Section/ReportSection.swift new file mode 100644 index 00000000..7567488c --- /dev/null +++ b/Mastodon/Diffiable/Section/ReportSection.swift @@ -0,0 +1,60 @@ +// +// 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: NeedsDependency, + managedObjectContext: NSManagedObjectContext, + timestampUpdatePublisher: AnyPublisher, + reportdStatusDelegate: ReportedStatusTableViewCellDelegate + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) {[ + weak dependency + ] tableView, indexPath, item -> UITableViewCell? in + guard let dependency = dependency else { return UITableViewCell() } + + switch item { + case .status(let objectID, let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportedStatusTableViewCell.self), for: indexPath) as! ReportedStatusTableViewCell + let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value + let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" + managedObjectContext.performAndWait { + 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 + ) + } + + let isSelected = reportdStatusDelegate.reportedStatus(cell: cell, isSelected: indexPath) + cell.setupSelected(isSelected) + return cell + default: + return nil + } + } + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index ce9e33e2..aba5e4f6 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -41,9 +41,11 @@ internal enum Asset { internal static let alertYellow = ColorAsset(name: "Colors/Background/alert.yellow") internal static let dangerBorder = ColorAsset(name: "Colors/Background/danger.border") internal static let danger = ColorAsset(name: "Colors/Background/danger") + internal static let elevatedPrimary = ColorAsset(name: "Colors/Background/elevatedPrimary") internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") internal static let navigationBar = ColorAsset(name: "Colors/Background/navigationBar") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") + internal static let secondary = ColorAsset(name: "Colors/Background/secondary") 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") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index b7bd3d0a..1fb5c5f5 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -96,6 +96,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 @@ -523,6 +525,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/Resources/Assets.xcassets/Colors/Background/elevatedPrimary.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/elevatedPrimary.colorset/Contents.json new file mode 100644 index 00000000..82edd034 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/elevatedPrimary.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" : "30", + "green" : "28", + "red" : "28" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.colorset/Contents.json new file mode 100644 index 00000000..5e706740 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "254", + "green" : "255", + "red" : "254" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "46", + "green" : "44", + "red" : "44" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 253b65d9..b413d887 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -31,6 +31,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"; @@ -168,6 +169,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"; @@ -227,4 +236,4 @@ any server."; "Scene.Thread.Reblog.Single" = "%@ reblog"; "Scene.Thread.Title" = "Post from %@"; "Scene.Welcome.Slogan" = "Social networking -back in your hands."; +back in your hands."; \ No newline at end of file diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 8bbf9436..45965200 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -41,6 +41,10 @@ extension HomeTimelineViewController { guard let self = self else { return } self.showSettings(action) }, + UIAction(title: "Report", image: UIImage(systemName: "exclamationmark.bubble"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showReportAction(action) + }, UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in guard let self = self else { return } self.signOutAction(action) @@ -330,5 +334,35 @@ extension HomeTimelineViewController { @objc private func showSettings(_ sender: UIAction) { coordinator.present(scene: .settings, from: self, transition: .modal(animated: true, completion: nil)) } + + @objc private func showReportAction(_ sender: UIAction) { + let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert) + alertController.addTextField() + alertController.addTextField() + guard let accountTextField = alertController.textFields?.first else { return } + guard let statusTextField = alertController.textFields?.last else { return } + accountTextField.placeholder = "User ID" + statusTextField.placeholder = "Status ID" + accountTextField.text = "212477" + statusTextField.text = "106103767536113615" + let showAction = UIAlertAction(title: "Show", style: .default) { [weak self] _ in + guard let self = self else { return } + + guard let userId = accountTextField.text else { return } + guard let statusId = statusTextField.text else { return } + + // itodo: delete them + // 31803 + // 106093402888557459 + self.coordinator.present( + scene: .report(userId: userId, statusId: statusId), + from: self, transition: .modal(animated: true, completion: nil)) + } + alertController.addAction(showAction) + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + } + } #endif diff --git a/Mastodon/Scene/Report/ReportView.swift b/Mastodon/Scene/Report/ReportView.swift new file mode 100644 index 00000000..9166259f --- /dev/null +++ b/Mastodon/Scene/Report/ReportView.swift @@ -0,0 +1,201 @@ +// +// 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 ReportViewHeader: UIView { + enum Step: Int { + case one + case two + } + + lazy var titleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = UIFont.preferredFont(forTextStyle: .subheadline) + label.numberOfLines = 0 + return label + }() + + lazy var contentLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.primary.color + label.font = UIFont.preferredFont(forTextStyle: .title3) + 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.elevatedPrimary.color + stackview.addArrangedSubview(titleLabel) + stackview.addArrangedSubview(contentLabel) + addSubview(stackview) + + stackview.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackview.safeAreaLayoutGuide.topAnchor.constraint( + equalTo: self.topAnchor, + constant: ReportView.verticalMargin + ), + stackview.leadingAnchor.constraint( + equalTo: self.readableContentGuide.leadingAnchor, + constant: ReportView.horizontalMargin + ), + stackview.bottomAnchor.constraint( + equalTo: self.bottomAnchor, + constant: -1 * ReportView.verticalMargin + ), + stackview.trailingAnchor.constraint( + equalTo: self.readableContentGuide.trailingAnchor, + constant: -1 * ReportView.horizontalMargin + ) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +final class ReportViewFooter: 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.elevatedPrimary.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 ReportView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview { () -> UIView in + let view = ReportViewHeader() + view.step = .one + view.contentLabel.preferredMaxLayoutWidth = 335 + return view + } + .previewLayout(.fixed(width: 375, height: 110)) + + UIViewPreview(width: 375) { () -> UIView in + return ReportViewFooter(frame: CGRect(origin: .zero, size: CGSize(width: 375, height: 164))) + } + .previewLayout(.fixed(width: 375, height: 164)) + } + } + +} + +#endif diff --git a/Mastodon/Scene/Report/ReportViewController.swift b/Mastodon/Scene/Report/ReportViewController.swift new file mode 100644 index 00000000..7d46ae6b --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewController.swift @@ -0,0 +1,267 @@ +// +// 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 + +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: ReportViewHeader = { + let view = ReportViewHeader() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var footer: ReportViewFooter = { + let view = ReportViewFooter() + 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.elevatedPrimary.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 + 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 + return textView + }() + + override func viewDidLoad() { + super.viewDidLoad() + + setupView() + 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) + + 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) + ]) + + 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(), + tableView: tableView + ) + 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?.reportSuccess + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] (_) in + self?.dismiss(animated: true, completion: nil) + }) + .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.userId) + 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?.displayName ?? "@\(beReportedUser?.acct ?? "")")" + ) + } + + 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.textView.trailingAnchor.constraint( + equalTo: self.contentView.safeAreaLayoutGuide.trailingAnchor, + constant: -1 * 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() + } + } +} + +extension ReportViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return + } + + didToggleSelected.send(item) + } +} + +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 00000000..4df2ccb2 --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewModel+Data.swift @@ -0,0 +1,98 @@ +// +// 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, + 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.statusId 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.statusId, !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.StatusAttribute] = [:] + let oldSnapshot = diffableDataSource.snapshot() + for item in oldSnapshot.itemIdentifiers { + guard case let .status(objectID, attribute) = item else { continue } + oldSnapshotAttributeDict[objectID] = attribute + } + + for objectID in objectIDs { + let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute() + let item = Item.status(objectID: objectID, attribute: attribute) + items.append(item) + + guard let status = managedObjectContext.object(with: objectID) as? Status else { + continue + } + if status.id == self.statusId { + self.selectedItems.append(item) + self.continueEnableSubject.send(true) + } + } + snapshot.appendItems(items, toSection: .main) + } + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/Report/ReportViewModel+Diffable.swift b/Mastodon/Scene/Report/ReportViewModel+Diffable.swift new file mode 100644 index 00000000..38f2edb1 --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewModel+Diffable.swift @@ -0,0 +1,37 @@ +// +// 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: NeedsDependency, + reportdStatusDelegate: ReportedStatusTableViewCellDelegate + ) { + 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, + reportdStatusDelegate: reportdStatusDelegate + ) + + // 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+Provider.swift b/Mastodon/Scene/Report/ReportViewModel+Provider.swift new file mode 100644 index 00000000..052fc2ba --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewModel+Provider.swift @@ -0,0 +1,86 @@ +//// +//// ReportViewModel+Provider.swift +//// Mastodon +//// +//// Created by ihugo on 2021/4/19. +//// +// +//import Combine +//import CoreData +//import CoreDataStack +//import Foundation +//import MastodonSDK +//import UIKit +//import os.log +// +//extension ReportViewController: StatusProvider { +// func status() -> Future { +// return Future { promise in promise(.success(nil)) } +// } +// +// func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { +// return Future { promise in +// guard let diffableDataSource = self.viewModel.diffableDataSource else { +// assertionFailure() +// promise(.success(nil)) +// return +// } +// guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), +// let item = diffableDataSource.itemIdentifier(for: indexPath) else { +// promise(.success(nil)) +// return +// } +// +// switch item { +// case .status(let objectID, _): +// let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext +// managedObjectContext.perform { +// let status = managedObjectContext.object(with: objectID) as? Status +// promise(.success(status)) +// } +// default: +// promise(.success(nil)) +// } +// } +// } +// +// func status(for cell: UICollectionViewCell) -> Future { +// return Future { promise in promise(.success(nil)) } +// } +// +// var managedObjectContext: NSManagedObjectContext { +// return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext +// } +// +// var tableViewDiffableDataSource: UITableViewDiffableDataSource? { +// return viewModel.diffableDataSource +// } +// +// func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { +// guard let diffableDataSource = self.viewModel.diffableDataSource else { +// assertionFailure() +// return nil +// } +// +// guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), +// let item = diffableDataSource.itemIdentifier(for: indexPath) else { +// return nil +// } +// +// return item +// } +// +// func items(indexPaths: [IndexPath]) -> [Item] { +// guard let diffableDataSource = self.viewModel.diffableDataSource else { +// assertionFailure() +// return [] +// } +// +// var items: [Item] = [] +// for indexPath in indexPaths { +// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } +// items.append(item) +// } +// return items +// } +//} diff --git a/Mastodon/Scene/Report/ReportViewModel.swift b/Mastodon/Scene/Report/ReportViewModel.swift new file mode 100644 index 00000000..94404af1 --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewModel.swift @@ -0,0 +1,249 @@ +// +// 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, NeedsDependency { + 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) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(coordinator == nil) } } + var userId: String + var statusId: String? + var selectedItems = [Item]() + var comment: String? + + internal var reportQuery: FileReportQuery + internal var disposeBag = Set() + internal let currentStep = CurrentValueSubject(.one) + internal let statusFetchedResultsController: StatusFetchedResultsController + internal var diffableDataSource: UITableViewDiffableDataSource? + internal let continueEnableSubject = CurrentValueSubject(false) + internal let sendEnableSubject = CurrentValueSubject(false) + internal let reportSuccess = PassthroughSubject() + + struct Input { + let didToggleSelected: AnyPublisher + let comment: AnyPublisher + let step1Continue: AnyPublisher + let step1Skip: AnyPublisher + let step2Continue: AnyPublisher + let step2Skip: AnyPublisher + let cancel: AnyPublisher + let tableView: UITableView + } + + struct Output { + let currentStep: AnyPublisher + let continueEnableSubject: AnyPublisher + let sendEnableSubject: AnyPublisher + let reportSuccess: AnyPublisher + } + + init(context: AppContext, + coordinator: SceneCoordinator, + domain: String, + userId: String, + statusId: String? + ) { + self.context = context + self.coordinator = coordinator + self.userId = userId + self.statusId = statusId + self.statusFetchedResultsController = StatusFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: domain, + additionalTweetPredicate: Status.notDeleted() + ) + + self.reportQuery = FileReportQuery( + accountId: userId, + statusIds: nil, + 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 + + setupDiffableDataSource( + for: input.tableView, + dependency: self, + reportdStatusDelegate: self + ) + + // data binding + bindData(input: input) + + // step1 and step2 binding + bindForStep1(input: input) + bindForStep2( + input: input, + domain: domain, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + + requestRecentStatus( + domain: domain, + accountId: self.userId, + authorizationBox: activeMastodonAuthenticationBox + ) + + fetchStatus() + + return Output( + currentStep: currentStep.eraseToAnyPublisher(), + continueEnableSubject: continueEnableSubject.eraseToAnyPublisher(), + sendEnableSubject: sendEnableSubject.eraseToAnyPublisher(), + reportSuccess: reportSuccess.eraseToAnyPublisher() + ) + } + + // MARK: - Private methods + func bindData(input: Input) { + input.didToggleSelected.sink { [weak self] (item) in + guard let self = self else { return } + guard case let .status(objectID, attribute) = item else { return } + guard var snapshot = self.diffableDataSource?.snapshot() else { return } + let managedObjectContext = self.statusFetchedResultsController.fetchedResultsController.managedObjectContext + guard let status = managedObjectContext.object(with: objectID) as? Status else { + return + } + + var items = [Item]() + if let index = self.selectedItems.firstIndex(of: item) { + self.selectedItems.remove(at: index) + items.append(.status(objectID: objectID, attribute: attribute)) + + if let index = self.reportQuery.statusIds?.firstIndex(of: status.id) { + self.reportQuery.statusIds?.remove(at: index) + } + } else { + self.selectedItems.append(item) + items.append(.status(objectID: objectID, attribute: attribute)) + self.reportQuery.statusIds?.append(status.id) + } + + snapshot.reloadItems([item]) + self.diffableDataSource?.apply(snapshot, animatingDifferences: false) + + let continueEnable = self.selectedItems.count > 0 + self.continueEnableSubject.send(continueEnable) + } + .store(in: &disposeBag) + + input.comment.assign( + to: \.comment, + on: self + ) + .store(in: &disposeBag) + input.comment.sink { [weak self] (comment) in + guard let self = self else { return } + 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.selectedItems.removeAll() + return value + } + + Publishers.Merge(skip, input.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) { + let skip = input.step2Skip.map { [weak self] value -> Void in + guard let self = self else { return value } + self.comment = nil + return value + } + + Publishers.Merge(skip, input.step2Continue) + .sink { [weak self] _ in + guard let self = self else { return } + let managedObjectContext = self.statusFetchedResultsController.fetchedResultsController.managedObjectContext + + self.reportQuery.comment = self.comment + + var selectedStatusIds = [String]() + self.selectedItems.forEach { (item) in + guard case .status(let objectId, _) = item else { + return + } + guard let status = managedObjectContext.object(with: objectId) as? Status else { + return + } + selectedStatusIds.append(status.id) + } + self.reportQuery.statusIds = selectedStatusIds + + self.context.apiService.report( + domain: domain, + query: self.reportQuery, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .sink { [weak self](data) in + switch data { + case .failure(let 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) + ) + case .finished: + self?.reportSuccess.send() + } + + } receiveValue: { (data) in + } + .store(in: &self.disposeBag) + } + .store(in: &disposeBag) + } +} + +extension ReportViewModel: ReportedStatusTableViewCellDelegate { + func reportedStatus(cell: ReportedStatusTableViewCell, isSelected indexPath: IndexPath) -> Bool { + guard let item = diffableDataSource?.itemIdentifier(for: indexPath) else { + return false + } + + return selectedItems.contains(item) + } +} diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift new file mode 100644 index 00000000..8c7bd221 --- /dev/null +++ b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift @@ -0,0 +1,176 @@ +// +// 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 + +protocol ReportedStatusTableViewCellDelegate: class { + func reportedStatus(cell: ReportedStatusTableViewCell, isSelected indexPath: IndexPath) -> Bool +} + +final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { + + static let bottomPaddingHeight: CGFloat = 10 + + weak var delegate: ReportedStatusTableViewCellDelegate? + var disposeBag = Set() + var pollCountdownSubscription: AnyCancellable? + var observations = Set() + var checked: Bool = false + + 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() + checked = false + statusView.isStatusTextSensitive = false + statusView.cleanUpContentWarning() + statusView.pollTableView.dataSource = nil + statusView.playerContainerView.reset() + 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() + 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 !checked { + checkbox.image = UIImage(systemName: "circle") + checkbox.tintColor = Asset.Colors.Label.secondary.color + } + } +} + +extension ReportedStatusTableViewCell { + + private func _init() { + backgroundColor = Asset.Colors.Background.systemBackground.color + statusView.contentWarningBlurContentImageView.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.actionToolbarContainer.isHidden = true + statusView.isUserInteractionEnabled = false + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + resetSeparatorLineLayout() + } + + func setupSelected(_ selected: Bool) { + checked = selected + if selected { + checkbox.image = UIImage(systemName: "checkmark.circle.fill") + } else { + checkbox.image = UIImage(systemName: "circle") + } + checkbox.tintColor = Asset.Colors.Label.secondary.color + } +} + +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, + ]) + } + } + } +} diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 4615f92a..156321d8 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/Service/APIService/APIService+Report.swift b/Mastodon/Service/APIService/APIService+Report.swift new file mode 100644 index 00000000..3c170c62 --- /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 00000000..eac7f64f --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift @@ -0,0 +1,79 @@ +// +// File.swift +// +// +// Created by ihugo on 2021/4/19. +// + +import Combine +import Foundation + +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/search/) + /// - 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 + if let response = response as? HTTPURLResponse { + return Mastodon.Response.Content( + value: response.statusCode == 200, + response: response + ) + } + return Mastodon.Response.Content(value: false, response: response) + } + .eraseToAnyPublisher() + } +} + + +public extension Mastodon.API.Reports { + class FileReportQuery: Codable, PostQuery { + public let accountId: String + public var statusIds: [String]? + 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: String, + statusIds: [String]?, + 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 1a4496ed..c85c2cd7 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 {