2
2
mirror of https://github.com/mastodon/mastodon-ios synced 2025-04-11 22:58:02 +02:00

Convert NotificationPolicyViewController to SwiftUI

Fixes IOS-396
Fixes IOS-398
This commit is contained in:
shannon 2025-04-08 08:20:00 -04:00
parent fa207e3a44
commit b72d5f34c5
8 changed files with 458 additions and 3 deletions

View File

@ -851,6 +851,7 @@
}
},
"admin_filter": {
"title": "Admin Notifications",
"reports": {
"title": "Admin reports",
"subtitle": "Show reports of spam, rule violations, and other complaints on this instance"

View File

@ -851,6 +851,7 @@
}
},
"admin_filter": {
"title": "Admin Notifications",
"reports": {
"title": "Admin reports",
"subtitle": "Show reports of spam, rule violations, and other complaints on this instance"

View File

@ -508,6 +508,7 @@
DBFEEC99279BDCDE004F81DD /* ProfileAboutViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEEC98279BDCDE004F81DD /* ProfileAboutViewModel.swift */; };
DBFEEC9B279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEEC9A279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift */; };
DBFEEC9D279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEEC9C279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift */; };
FB0F8E932DA54A180058C09E /* NotificationPolicyHostingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB0F8E922DA54A090058C09E /* NotificationPolicyHostingViewController.swift */; };
FB7C4CC62CD2CAB000F6129A /* DonationCompletionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7C4CC52CD2CAA800F6129A /* DonationCompletionViewController.swift */; };
FB7C4CCC2CD55DEB00F6129A /* NavigationFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7C4CCB2CD55DEB00F6129A /* NavigationFlow.swift */; };
FB7C4CCE2CD55DFF00F6129A /* NewDonationNavigationFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7C4CCD2CD55DFE00F6129A /* NewDonationNavigationFlow.swift */; };
@ -1261,6 +1262,7 @@
DBFEF06726A58D07006D7ED1 /* ShareActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareActionExtension.entitlements; sourceTree = "<group>"; };
E9AABD3D26B64B8C00E237DA /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Intents.strings; sourceTree = "<group>"; };
E9AABD4026B64B8D00E237DA /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = "<group>"; };
FB0F8E922DA54A090058C09E /* NotificationPolicyHostingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicyHostingViewController.swift; sourceTree = "<group>"; };
FB7C4CC52CD2CAA800F6129A /* DonationCompletionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationCompletionViewController.swift; sourceTree = "<group>"; };
FB7C4CCB2CD55DEB00F6129A /* NavigationFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationFlow.swift; sourceTree = "<group>"; };
FB7C4CCD2CD55DFE00F6129A /* NewDonationNavigationFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewDonationNavigationFlow.swift; sourceTree = "<group>"; };
@ -1843,6 +1845,7 @@
D85DF9682C481AF400A01408 /* NotificationPolicyFilterTableViewCell.swift */,
D85DF96A2C481AF700A01408 /* NotificationPolicyHeaderView.swift */,
D85DF9692C481AF700A01408 /* NotificationPolicyViewController.swift */,
FB0F8E922DA54A090058C09E /* NotificationPolicyHostingViewController.swift */,
);
path = "Notification Filtering";
sourceTree = "<group>";
@ -3849,6 +3852,7 @@
DB63F77B279ACAE500455B82 /* DataSourceFacade+Favorite.swift in Sources */,
2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */,
D8D688F92AB8B970000F651A /* SearchResultOverviewCoordinator.swift in Sources */,
FB0F8E932DA54A180058C09E /* NotificationPolicyHostingViewController.swift in Sources */,
DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */,
2A3D9B7E29A8F33A00F30313 /* StatusHistoryView.swift in Sources */,
D81A227B2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift in Sources */,

View File

@ -198,7 +198,7 @@ extension SceneCoordinator {
case settings(setting: Setting)
// Notifications
case notificationPolicy(viewModel: NotificationFilterViewModel)
case notificationPolicy(viewModel: NotificationPolicyViewModel)
case notificationRequests(viewModel: NotificationRequestsViewModel)
case accountNotificationTimeline(viewModel: NotificationTimelineViewModel, request: Mastodon.Entity.NotificationRequest)
@ -548,7 +548,7 @@ private extension SceneCoordinator {
case .notificationRequests(let viewModel):
viewController = NotificationRequestsTableViewController(viewModel: viewModel)
case .notificationPolicy(let viewModel):
viewController = NotificationPolicyViewController(viewModel: viewModel)
viewController = NotificationPolicyViewController(viewModel)
case .accountNotificationTimeline(let viewModel, let request):
viewController = AccountNotificationTimelineViewController(viewModel: viewModel, notificationRequest: request)
}

View File

@ -74,7 +74,7 @@ class NotificationListViewController: UIHostingController<NotificationListView>
}
}()
let policyViewModel = await NotificationFilterViewModel(
let policyViewModel = await NotificationPolicyViewModel(
NotificationFilterSettings(
forNotFollowing: policy.forNotFollowing,
forNotFollowers: policy.forNotFollowers,

View File

@ -0,0 +1,446 @@
// Copyright © 2025 Mastodon gGmbH. All rights reserved.
import MastodonAsset
import MastodonCore
import MastodonLocalization
import MastodonSDK
import SwiftUI
private typealias FilterAction = Mastodon.Entity.NotificationPolicy
.NotificationFilterAction
protocol NotificationPolicyViewControllerDelegate: AnyObject {
func policyUpdated(
_ viewController: NotificationPolicyViewController,
newPolicy: Mastodon.Entity.NotificationPolicy)
}
class NotificationPolicyViewController: UIHostingController<
NotificationPolicyView
>
{
let viewModel: NotificationPolicyViewModel
weak var delegate: NotificationPolicyViewControllerDelegate?
init(_ viewModel: NotificationPolicyViewModel) {
self.viewModel = viewModel
let root = NotificationPolicyView(viewModel: viewModel)
super.init(rootView: root)
viewModel.dismissView = { [weak self] in
self?.dismiss(animated: true)
}
viewModel.didDismissView = { updatedPolicy in
self.didUpdatePolicy(updatedPolicy)
viewModel.didDismissView = nil // break retain cycle
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init with coder not implemented")
}
private func didUpdatePolicy(
_ updatedPolicy: Mastodon.Entity.NotificationPolicy?
) {
if let updatedPolicy {
delegate?.policyUpdated(self, newPolicy: updatedPolicy)
}
NotificationCenter.default.post(
name: .notificationFilteringChanged, object: nil)
}
}
struct NotificationPolicyView: View {
@StateObject var viewModel: NotificationPolicyViewModel
var body: some View {
VStack {
HStack {
Spacer()
.frame(maxWidth: .infinity)
Button {
viewModel.dismissView?()
} label: {
Image(systemName: "x.circle")
.frame(width: 45, height: 45)
.font(.title)
}
}
.padding()
List {
ForEach(viewModel.sections, id: \.self) { section in
Section(header: Text(section.headerText).font(.title)) {
ForEach(section.items, id: \.self) { policyItem in
rowView(policyItem)
}
}
.textCase(nil)
}
}
.listStyle(.insetGrouped)
Spacer()
}
.background(Color(uiColor: .systemGroupedBackground))
.onDisappear {
Task {
let updatedPolicy = try await viewModel.saveChanges()
viewModel.didDismissView?(updatedPolicy)
}
}
}
@ViewBuilder func rowView(
_ settingType: NotificationPolicyViewModel.NotificationFilterItem
) -> some View {
VStack(alignment: .leading) {
// title
HStack(alignment: verticalAlignmentForControl(settingType)) {
Text(settingType.title)
.multilineTextAlignment(.leading)
.font(.headline)
Spacer()
// menu or toggle
control(settingType)
.fixedSize()
}
HStack {
Spacer().frame(width: 5)
// subtitle
Text(settingType.subtitle)
.multilineTextAlignment(.leading)
.font(.subheadline)
Spacer()
}
}
.frame(maxWidth: .infinity)
}
@ViewBuilder func control(_ settingType: NotificationPolicyViewModel.NotificationFilterItem) -> some View {
// the control
switch settingType {
case .notFollowing, .notFollowers, .newAccounts, .privateMentions,
.limitedAccounts:
Picker("", selection: viewModel.binding(for: settingType)) {
ForEach([FilterAction.accept, .filter, .drop], id: \.self) {
option in
Text(option.displayTitle)
}
}
.pickerStyle(.menu)
.tint(Asset.Colors.Brand.blurple.swiftUIColor)
.fixedSize()
case .adminReports, .adminSignups:
Toggle(
isOn: Binding(
get: {
viewModel.value(forItem: settingType) == .accept
},
set: {
viewModel.setValue(
$0 ? .accept : .filter, forItem: settingType)
})
) {}
.tint(Asset.Colors.Brand.blurple.swiftUIColor)
.fixedSize()
}
}
func verticalAlignmentForControl(_ settingType: NotificationPolicyViewModel.NotificationFilterItem) -> VerticalAlignment {
switch settingType {
case .notFollowing, .notFollowers, .newAccounts, .privateMentions, .limitedAccounts:
.center
case .adminReports, .adminSignups:
.top
}
}
}
@MainActor
class NotificationPolicyViewModel: ObservableObject {
let sections: [NotificationPolicyViewModel.NotificationFilterSection]
let originalRegularSettings: NotificationFilterSettings
let originalAdminSettings: AdminNotificationFilterSettings?
var dismissView: (() -> Void)?
var didDismissView: ((Mastodon.Entity.NotificationPolicy?) -> Void)?
@Published var regularFilterSettings: NotificationFilterSettings
@Published var adminFilterSettings: AdminNotificationFilterSettings?
var hasUnsavedChangesToRegularSettings: Bool {
return regularFilterSettings != originalRegularSettings
}
var hasUnsavedChangesToAdminSettings: Bool {
return adminFilterSettings != originalAdminSettings
}
init(
_ regularSettings: NotificationFilterSettings,
adminSettings: AdminNotificationFilterSettings?
) async {
self.originalRegularSettings = regularSettings
self.regularFilterSettings = regularSettings
self.originalAdminSettings = adminSettings
self.adminFilterSettings = adminSettings
self.sections = [.main, .admin].compactMap { $0 }
}
fileprivate func value(forItem item: NotificationFilterItem) -> FilterAction
{
switch item {
case .notFollowing:
return regularFilterSettings.forNotFollowing
case .notFollowers:
return regularFilterSettings.forNotFollowers
case .newAccounts:
return regularFilterSettings.forNewAccounts
case .privateMentions:
return regularFilterSettings.forPrivateMentions
case .limitedAccounts:
return regularFilterSettings.forLimitedAccounts
case .adminReports:
return adminFilterSettings?.forReports ?? .drop
case .adminSignups:
return adminFilterSettings?.forSignups ?? .drop
}
}
fileprivate func setValue(
_ value: FilterAction, forItem item: NotificationFilterItem
) {
switch item {
case .notFollowing:
regularFilterSettings = NotificationFilterSettings(
forNotFollowing: value,
forNotFollowers: regularFilterSettings.forNotFollowers,
forNewAccounts: regularFilterSettings.forNewAccounts,
forPrivateMentions: regularFilterSettings.forPrivateMentions,
forLimitedAccounts: regularFilterSettings.forLimitedAccounts)
case .notFollowers:
regularFilterSettings = NotificationFilterSettings(
forNotFollowing: regularFilterSettings.forNotFollowing,
forNotFollowers: value,
forNewAccounts: regularFilterSettings.forNewAccounts,
forPrivateMentions: regularFilterSettings.forPrivateMentions,
forLimitedAccounts: regularFilterSettings.forLimitedAccounts)
case .newAccounts:
regularFilterSettings = NotificationFilterSettings(
forNotFollowing: regularFilterSettings.forNotFollowing,
forNotFollowers: regularFilterSettings.forNotFollowers,
forNewAccounts: value,
forPrivateMentions: regularFilterSettings.forPrivateMentions,
forLimitedAccounts: regularFilterSettings.forLimitedAccounts)
case .privateMentions:
regularFilterSettings = NotificationFilterSettings(
forNotFollowing: regularFilterSettings.forNotFollowing,
forNotFollowers: regularFilterSettings.forNotFollowers,
forNewAccounts: regularFilterSettings.forNewAccounts,
forPrivateMentions: value,
forLimitedAccounts: regularFilterSettings.forLimitedAccounts)
case .limitedAccounts:
regularFilterSettings = NotificationFilterSettings(
forNotFollowing: regularFilterSettings.forNotFollowing,
forNotFollowers: regularFilterSettings.forNotFollowers,
forNewAccounts: regularFilterSettings.forNewAccounts,
forPrivateMentions: regularFilterSettings.forPrivateMentions,
forLimitedAccounts: value)
case .adminReports:
guard let adminFilterSettings else { return }
self.adminFilterSettings = AdminNotificationFilterSettings(
forReports: value,
forSignups: adminFilterSettings.forSignups)
case .adminSignups:
guard let adminFilterSettings else { return }
self.adminFilterSettings = AdminNotificationFilterSettings(
forReports: adminFilterSettings.forReports,
forSignups: value)
}
}
func saveChanges() async throws -> Mastodon.Entity.NotificationPolicy? {
guard
let authenticationBox = AuthenticationServiceProvider.shared
.currentActiveUser.value
else { return nil }
if let adminFilterSettings, hasUnsavedChangesToAdminSettings {
do {
try await BodegaPersistence.Notifications.updatePreferences(
adminFilterSettings, for: authenticationBox)
} catch {}
}
if hasUnsavedChangesToRegularSettings {
let updatedPolicy = try await APIService.shared
.updateNotificationPolicy(
authenticationBox: authenticationBox,
forNotFollowing: value(forItem: .notFollowing),
forNotFollowers: value(forItem: .notFollowers),
forNewAccounts: value(forItem: .newAccounts),
forPrivateMentions: value(forItem: .privateMentions),
forLimitedAccounts: value(forItem: .limitedAccounts)
).value
return updatedPolicy
} else {
return nil
}
}
}
extension NotificationPolicyViewModel {
fileprivate func binding(for settingItem: NotificationFilterItem)
-> Binding<FilterAction>
{
return Binding(
get: { [weak self] in
self?.value(forItem: settingItem) ?? ._other("unset")
}, set: { [weak self] in self?.setValue($0, forItem: settingItem) })
}
}
extension NotificationPolicyViewModel {
enum NotificationFilterSection: Hashable {
case main
case admin
var items: [NotificationFilterItem] {
switch self {
case .main:
return [
.notFollowing, .notFollowers, .newAccounts,
.privateMentions, .limitedAccounts,
]
case .admin:
return [.adminReports, .adminSignups]
}
}
var headerText: String {
switch self {
case .main:
L10n.Scene.Notification.Policy.title
case .admin:
L10n.Scene.Notification.AdminFilter.title
}
}
}
enum NotificationFilterItem: Hashable {
case notFollowing
case notFollowers
case newAccounts
case privateMentions
case limitedAccounts
case adminReports
case adminSignups
static let regularOptions = [
Self.notFollowing, .notFollowers, .newAccounts, .privateMentions,
.limitedAccounts,
]
static let adminOptions = [Self.adminReports, .adminSignups]
var title: String {
switch self {
case .notFollowing:
return L10n.Scene.Notification.Policy.NotFollowing.title
case .notFollowers:
return L10n.Scene.Notification.Policy.NoFollower.title
case .newAccounts:
return L10n.Scene.Notification.Policy.NewAccount.title
case .privateMentions:
return L10n.Scene.Notification.Policy.PrivateMentions.title
case .limitedAccounts:
return L10n.Scene.Notification.Policy.ModeratedAccounts.title
case .adminReports:
return L10n.Scene.Notification.AdminFilter.Reports.title
case .adminSignups:
return L10n.Scene.Notification.AdminFilter.Signups.title
}
}
var subtitle: String {
switch self {
case .notFollowing:
return L10n.Scene.Notification.Policy.NotFollowing.subtitle
case .notFollowers:
return L10n.Scene.Notification.Policy.NoFollower.subtitle
case .newAccounts:
return L10n.Scene.Notification.Policy.NewAccount.subtitle
case .privateMentions:
return L10n.Scene.Notification.Policy.PrivateMentions.subtitle
case .limitedAccounts:
return L10n.Scene.Notification.Policy.ModeratedAccounts.subtitle
case .adminReports:
return L10n.Scene.Notification.AdminFilter.Reports.subtitle
case .adminSignups:
return L10n.Scene.Notification.AdminFilter.Signups.subtitle
}
}
}
}
struct NotificationFilterSettings: Codable, Equatable {
let forNotFollowing:
Mastodon.Entity.NotificationPolicy.NotificationFilterAction
let forNotFollowers:
Mastodon.Entity.NotificationPolicy.NotificationFilterAction
let forNewAccounts:
Mastodon.Entity.NotificationPolicy.NotificationFilterAction
let forPrivateMentions:
Mastodon.Entity.NotificationPolicy.NotificationFilterAction
let forLimitedAccounts:
Mastodon.Entity.NotificationPolicy.NotificationFilterAction
}
struct AdminNotificationFilterSettings: Codable, Equatable {
let forReports: Mastodon.Entity.NotificationPolicy.NotificationFilterAction
let forSignups: Mastodon.Entity.NotificationPolicy.NotificationFilterAction
var excludedNotificationTypes: [Mastodon.Entity.NotificationType]? {
var excluded = [Mastodon.Entity.NotificationType]()
if forReports != .accept {
excluded.append(.adminReport)
}
if forSignups != .accept {
excluded.append(.adminSignUp)
}
return excluded.isEmpty ? nil : excluded
}
}
extension FilterAction {
var displayTitle: String {
switch self {
case .accept: return L10n.Scene.Notification.Policy.Action.Accept.title
case .filter: return L10n.Scene.Notification.Policy.Action.Filter.title
case .drop: return L10n.Scene.Notification.Policy.Action.Drop.title
case ._other(let string): return string
}
}
var displaySubtitle: String {
switch self {
case .accept: return L10n.Scene.Notification.Policy.Action.Accept.subtitle
case .filter: return L10n.Scene.Notification.Policy.Action.Filter.subtitle
case .drop: return L10n.Scene.Notification.Policy.Action.Drop.subtitle
case ._other: return ""
}
}
}

View File

@ -944,6 +944,8 @@ public enum L10n {
/// View report
public static let viewReport = L10n.tr("Localizable", "Scene.Notification.ViewReport", fallback: "View report")
public enum AdminFilter {
/// Admin Notifications
public static let title = L10n.tr("Localizable", "Scene.Notification.AdminFilter.Title", fallback: "Admin Notifications")
public enum Reports {
/// Show reports of spam, rule violations, and other complaints
public static let subtitle = L10n.tr("Localizable", "Scene.Notification.AdminFilter.Reports.Subtitle", fallback: "Show reports of spam, rule violations, and other complaints")

View File

@ -400,6 +400,7 @@ Please retry in a few minutes.";
"Scene.Notification.Policy.Action.Drop.Title" = "Ignore";
"Scene.Notification.Policy.Action.Drop.Subtitle" = "Send to the void, never to be seen again";
"Scene.Notification.Policy.Title" = "Filter Notifications from…";
"Scene.Notification.AdminFilter.Title" = "Admin Notifications";
"Scene.Notification.AdminFilter.Reports.Title" = "Admin reports";
"Scene.Notification.AdminFilter.Reports.Subtitle" = "Show reports of spam, rule violations, and other complaints";
"Scene.Notification.AdminFilter.Signups.Title" = "Account signups";