feat: report

This commit is contained in:
ihugo 2021-04-19 20:34:08 +08:00
parent eb89ff6bdc
commit e3df692c3f
22 changed files with 1504 additions and 10 deletions

View File

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

View File

@ -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 youd 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"
}
}
}

View File

@ -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 = "<group>"; };
5B24BBD7262DB14800A9381B /* ReportViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReportViewModel.swift; sourceTree = "<group>"; };
5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Diffable.swift"; sourceTree = "<group>"; };
5B24BBD9262DB14800A9381B /* ReportViewModel+Provider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Provider.swift"; sourceTree = "<group>"; };
5B24BBE1262DB19100A9381B /* APIService+Report.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Report.swift"; sourceTree = "<group>"; };
5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsToggleTableViewCell.swift; sourceTree = "<group>"; };
5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = "<group>"; };
@ -558,6 +571,11 @@
5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = "<group>"; };
5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Subscriptions.swift"; sourceTree = "<group>"; };
5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Subscriptions.swift"; sourceTree = "<group>"; };
5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewController.swift; sourceTree = "<group>"; };
5BB04FDA262EA3070043BFF6 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = "<group>"; };
5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportedStatusTableviewCell.swift; sourceTree = "<group>"; };
5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Data.swift"; sourceTree = "<group>"; };
5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportSection.swift; sourceTree = "<group>"; };
5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = "<group>"; };
5D0393952612D266007FE196 /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = "<group>"; };
5DA732CB2629CEF500A92342 /* UIView+Remove.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Remove.swift"; sourceTree = "<group>"; };
@ -1114,6 +1132,7 @@
2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */,
5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */,
);
path = Section;
sourceTree = "<group>";
@ -1217,6 +1236,20 @@
name = Frameworks;
sourceTree = "<group>";
};
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 = "<group>";
};
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;

View File

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

View File

@ -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<Date, Never>,
reportdStatusDelegate: ReportedStatusTableViewCellDelegate
) -> UITableViewDiffableDataSource<ReportSection, Item> {
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
}
}
}
}

View File

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

View File

@ -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 youd 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<AnyCancellable>()
let didToggleSelected = PassthroughSubject<Item, Never>()
let comment = CurrentValueSubject<String?, Never>(nil)
let step1Continue = PassthroughSubject<Void, Never>()
let step1Skip = PassthroughSubject<Void, Never>()
let step2Continue = PassthroughSubject<Void, Never>()
let step2Skip = PassthroughSubject<Void, Never>()
let cancel = PassthroughSubject<Void, Never>()
// 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)
}
}

View File

@ -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<ReportSection, Item>()
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)
}
}

View File

@ -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<ReportSection, Item>()
snapshot.appendSections([.main])
diffableDataSource?.apply(snapshot)
}
}

View File

@ -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<Status?, Never> {
// return Future { promise in promise(.success(nil)) }
// }
//
// func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future<Status?, Never> {
// 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<Status?, Never> {
// return Future { promise in promise(.success(nil)) }
// }
//
// var managedObjectContext: NSManagedObjectContext {
// return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext
// }
//
// var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
// 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
// }
//}

View File

@ -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<AnyCancellable>()
internal let currentStep = CurrentValueSubject<Step, Never>(.one)
internal let statusFetchedResultsController: StatusFetchedResultsController
internal var diffableDataSource: UITableViewDiffableDataSource<ReportSection, Item>?
internal let continueEnableSubject = CurrentValueSubject<Bool, Never>(false)
internal let sendEnableSubject = CurrentValueSubject<Bool, Never>(false)
internal let reportSuccess = PassthroughSubject<Void, Never>()
struct Input {
let didToggleSelected: AnyPublisher<Item, Never>
let comment: AnyPublisher<String?, Never>
let step1Continue: AnyPublisher<Void, Never>
let step1Skip: AnyPublisher<Void, Never>
let step2Continue: AnyPublisher<Void, Never>
let step2Skip: AnyPublisher<Void, Never>
let cancel: AnyPublisher<Void, Never>
let tableView: UITableView
}
struct Output {
let currentStep: AnyPublisher<Step, Never>
let continueEnableSubject: AnyPublisher<Bool, Never>
let sendEnableSubject: AnyPublisher<Bool, Never>
let reportSuccess: AnyPublisher<Void, Never>
}
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)
}
}

View File

@ -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<AnyCancellable>()
var pollCountdownSubscription: AnyCancellable?
var observations = Set<NSKeyValueObservation>()
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,
])
}
}
}
}

View File

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

View File

@ -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<Mastodon.Response.Content<Bool>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
return Mastodon.API.Reports.fileReport(session: session, domain: domain, query: query, authorization: authorization)
}
}

View File

@ -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<Mastodon.Response.Content<Bool>, 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
}
}
}

View File

@ -116,6 +116,7 @@ extension Mastodon.API {
public enum Suggestions { }
public enum Notifications { }
public enum Subscriptions { }
public enum Reports { }
}
extension Mastodon.API {