diff --git a/CHANGELOG.md b/CHANGELOG.md index a2615a1c9..06e9f599d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added SwiftSoup to parse Open Graph metadata. [#1165](https://github.com/planetary-social/nos/issues/1165) - Parse Open Graph metadata whenever an event contains a URL, doesn’t have `imeta` tags, and the URL points to an HTML document. [#1425](https://github.com/planetary-social/nos/issues/1425) - Added a new flow to flag notes. Currently behind the “Enable new moderation flow” feature flag. [#1489](https://github.com/planetary-social/nos/issues/1489) +- Added a new flow to flag users. Currently behind the “Enable new moderation flow” feature flag. [#1493](https://github.com/planetary-social/nos/issues/1493) ## [0.1.26] - 2024-09-09Z diff --git a/Nos.xcodeproj/project.pbxproj b/Nos.xcodeproj/project.pbxproj index 9853b1ebc..54af0a8a2 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -119,10 +119,12 @@ 03FE3F7C2C87AC9900D25810 /* Event+InlineMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03FE3F7B2C87AC9900D25810 /* Event+InlineMetadata.swift */; }; 03FE3F7D2C87AC9900D25810 /* Event+InlineMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03FE3F7B2C87AC9900D25810 /* Event+InlineMetadata.swift */; }; 03FE3F8C2C87BC9500D25810 /* text_note_multiple_media.json in Resources */ = {isa = PBXBuildFile; fileRef = 03FE3F8A2C87BC9500D25810 /* text_note_multiple_media.json */; }; + 041C56C42CA1B48E007D3BB2 /* UserFlagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041C56C32CA1B48E007D3BB2 /* UserFlagView.swift */; }; 042406F32C907A15008F2A21 /* NosToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 042406F22C907A15008F2A21 /* NosToggle.swift */; }; 04368D2B2C99A2C400DEAA2E /* FlagOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04368D2A2C99A2C400DEAA2E /* FlagOption.swift */; }; 04368D312C99A78800DEAA2E /* NosRadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04368D302C99A78800DEAA2E /* NosRadioButton.swift */; }; 04368D4B2C99CFC700DEAA2E /* ContentFlagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04368D4A2C99CFC700DEAA2E /* ContentFlagView.swift */; }; + 045EDCF32CAAF47600B67964 /* FlagSuccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045EDCF22CAAF47600B67964 /* FlagSuccessView.swift */; }; 0496D6312C975E6900D29375 /* FlagOptionPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0496D6302C975E6900D29375 /* FlagOptionPicker.swift */; }; 2D06BB9D2AE249D70085F509 /* ThreadRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D06BB9C2AE249D70085F509 /* ThreadRootView.swift */; }; 2D4010A22AD87DF300F93AD4 /* KnownFollowersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4010A12AD87DF300F93AD4 /* KnownFollowersView.swift */; }; @@ -668,10 +670,12 @@ 03FE3F782C87A9D900D25810 /* EventError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventError.swift; sourceTree = ""; }; 03FE3F7B2C87AC9900D25810 /* Event+InlineMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Event+InlineMetadata.swift"; sourceTree = ""; }; 03FE3F8A2C87BC9500D25810 /* text_note_multiple_media.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = text_note_multiple_media.json; sourceTree = ""; }; + 041C56C32CA1B48E007D3BB2 /* UserFlagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFlagView.swift; sourceTree = ""; }; 042406F22C907A15008F2A21 /* NosToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NosToggle.swift; sourceTree = ""; }; 04368D2A2C99A2C400DEAA2E /* FlagOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagOption.swift; sourceTree = ""; }; 04368D302C99A78800DEAA2E /* NosRadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NosRadioButton.swift; sourceTree = ""; }; 04368D4A2C99CFC700DEAA2E /* ContentFlagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentFlagView.swift; sourceTree = ""; }; + 045EDCF22CAAF47600B67964 /* FlagSuccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagSuccessView.swift; sourceTree = ""; }; 0496D6302C975E6900D29375 /* FlagOptionPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagOptionPicker.swift; sourceTree = ""; }; 2D06BB9C2AE249D70085F509 /* ThreadRootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThreadRootView.swift; sourceTree = ""; }; 2D4010A12AD87DF300F93AD4 /* KnownFollowersView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KnownFollowersView.swift; sourceTree = ""; }; @@ -1458,6 +1462,8 @@ isa = PBXGroup; children = ( 04368D4A2C99CFC700DEAA2E /* ContentFlagView.swift */, + 041C56C32CA1B48E007D3BB2 /* UserFlagView.swift */, + 045EDCF22CAAF47600B67964 /* FlagSuccessView.swift */, ); path = Moderation; sourceTree = ""; @@ -2325,6 +2331,7 @@ C9C2B78229E0735400548B4A /* RelaySubscriptionManager.swift in Sources */, 3FFB1D9629A6BBEC002A755D /* Collection+SafeSubscript.swift in Sources */, A34E439929A522F20057AFCB /* CurrentUser.swift in Sources */, + 045EDCF32CAAF47600B67964 /* FlagSuccessView.swift in Sources */, 03E1812F2C753C9B00886CC6 /* ImageButton.swift in Sources */, C9A0DADD29C689C900466635 /* NosNavigationBar.swift in Sources */, 3F30020529C1FDD9003D4F8B /* OnboardingStartView.swift in Sources */, @@ -2397,6 +2404,7 @@ C9F75AD22A02D41E005BBE45 /* ComposerActionBar.swift in Sources */, 2D06BB9D2AE249D70085F509 /* ThreadRootView.swift in Sources */, C9DEBFD2298941000078B43A /* NosApp.swift in Sources */, + 041C56C42CA1B48E007D3BB2 /* UserFlagView.swift in Sources */, 5BFBB28B2BD9D79F002E909F /* URLParser.swift in Sources */, C930055F2A6AF8320098CA9E /* LoadingContent.swift in Sources */, 5B79F6462BA11725002DA9BE /* WizardSheetVStack.swift in Sources */, diff --git a/Nos/Models/FlagOption.swift b/Nos/Models/FlagOption.swift index f1942c2d1..29656c003 100644 --- a/Nos/Models/FlagOption.swift +++ b/Nos/Models/FlagOption.swift @@ -84,7 +84,7 @@ struct FlagOption: Identifiable, Equatable { title: String(localized: .localizable.flagUserDontMuteTitle), description: String(localized: .localizable.flagUserDontMuteDescription), info: nil, - category: .visibility(.unmute) + category: .visibility(.dontMute) ) ] @@ -153,5 +153,5 @@ enum SendFlagPrivacy { /// Specifies whether a flagged user should be muted or not. enum FlagUserVisibility { case mute - case unmute + case dontMute } diff --git a/Nos/Views/Components/FlagOptionPicker.swift b/Nos/Views/Components/FlagOptionPicker.swift index 9f6130731..9cc0e7590 100644 --- a/Nos/Views/Components/FlagOptionPicker.swift +++ b/Nos/Views/Components/FlagOptionPicker.swift @@ -56,6 +56,7 @@ struct FlagOptionPicker: View { } .background(LinearGradient.cardBackground) .clipShape(RoundedRectangle(cornerRadius: 15)) + .mimicCardButtonStyle() } } diff --git a/Nos/Views/Moderation/ContentFlagView.swift b/Nos/Views/Moderation/ContentFlagView.swift index 8908e6feb..cf5829984 100644 --- a/Nos/Views/Moderation/ContentFlagView.swift +++ b/Nos/Views/Moderation/ContentFlagView.swift @@ -9,6 +9,9 @@ struct ContentFlagView: View { /// The target of the report. let flagTarget: ReportTarget + + /// Defines the action to be performed when the user sends a flag report. + /// It is called when the user taps the "Send" button after selecting all required options. var sendAction: () -> Void @Environment(\.dismiss) private var dismiss @@ -20,7 +23,7 @@ struct ContentFlagView: View { Color.appBg.ignoresSafeArea() Group { if showSuccessView { - successView + FlagSuccessView() } else { categoryView } @@ -30,13 +33,13 @@ struct ContentFlagView: View { .nosNavigationBar(title: .localizable.flagContent) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button(action: { + Button { dismiss() resetSelections() - }, label: { + } label: { Text(.localizable.cancel) .foregroundColor(.primaryTxt) - }) + } .opacity(showSuccessView ? 0 : 1) .disabled(showSuccessView) } @@ -53,7 +56,6 @@ struct ContentFlagView: View { } } ) - .opacity(selectedSendOptionCategory == nil ? 0.5 : 1) .disabled(selectedSendOptionCategory == nil) } } @@ -92,27 +94,6 @@ struct ContentFlagView: View { } } } - - private var successView: some View { - VStack(spacing: 30) { - Image.circularCheckmark - .resizable() - .aspectRatio(contentMode: .fit) - .frame(height: 116) - - Text(String(localized: .localizable.thanksForTag)) - .foregroundColor(.primaryTxt) - .font(.clarity(.regular, textStyle: .title2)) - .padding(.horizontal, 62) - - Text(String(localized: .localizable.keepOnHelpingUs)) - .padding(.horizontal, 68) - .foregroundColor(.secondaryTxt) - .multilineTextAlignment(.center) - .lineSpacing(6) - .font(.clarity(.regular, textStyle: .subheadline)) - } - } } #Preview { diff --git a/Nos/Views/Moderation/FlagSuccessView.swift b/Nos/Views/Moderation/FlagSuccessView.swift new file mode 100644 index 000000000..882414480 --- /dev/null +++ b/Nos/Views/Moderation/FlagSuccessView.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// Shows a success message after the user has successfully flagged a user or content +struct FlagSuccessView: View { + var body: some View { + VStack(spacing: 30) { + Image.circularCheckmark + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 116) + + Text(String(localized: .localizable.thanksForTag)) + .foregroundColor(.primaryTxt) + .font(.clarity(.regular, textStyle: .title2)) + .padding(.horizontal, 25) + + Text(String(localized: .localizable.keepOnHelpingUs)) + .padding(.horizontal, 25) + .foregroundColor(.secondaryTxt) + .multilineTextAlignment(.center) + .lineSpacing(6) + .font(.clarity(.regular, textStyle: .subheadline)) + } + } +} + +#Preview { + FlagSuccessView() +} diff --git a/Nos/Views/Moderation/UserFlagView.swift b/Nos/Views/Moderation/UserFlagView.swift new file mode 100644 index 000000000..6eb42c269 --- /dev/null +++ b/Nos/Views/Moderation/UserFlagView.swift @@ -0,0 +1,156 @@ +import SwiftUI + +/// Displays pickers for selecting user flag options, with additional stages shown +/// based on previous selections. +struct UserFlagView: View { + @Binding var selectedFlagOption: FlagOption? + @Binding var selectedSendOption: FlagOption? + @Binding var selectedVisibilityOption: FlagOption? + @Binding var showSuccessView: Bool + + @Environment(\.dismiss) private var dismiss + + @State private var flagCategories: [FlagOption] = [] + + /// The target of the report. + let flagTarget: ReportTarget + + /// Defines the action to be performed when the user sends a flag report. + /// It is called when the user taps the "Send" button after selecting all required options. + var sendAction: () -> Void + + /// Indicates whether the target of the report is muted. + var isUserMuted: Bool { + flagTarget.author?.muted ?? false + } + + /// Determines if the send button should be disabled. + /// + /// The button's disabled state depends on two factors: + /// - For muted users: disabled when no send option is selected. + /// - For non-muted users: disabled when no visibility option is selected. + var isSendButtonDisabled: Bool { + if isUserMuted { + return selectedSendOption == nil + } + return selectedVisibilityOption == nil + } + + var body: some View { + ZStack { + Color.appBg.ignoresSafeArea() + Group { + if showSuccessView { + FlagSuccessView() + } else { + categoryView + } + } + .padding() + .nosNavigationBar(title: .localizable.flagUserTitle) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + resetSelections() + } label: { + Text(.localizable.cancel) + .foregroundColor(.primaryTxt) + } + .opacity(showSuccessView ? 0 : 1) + .disabled(showSuccessView) + } + ToolbarItem(placement: .navigationBarTrailing) { + ActionButton( + title: showSuccessView ? .localizable.done : .localizable.send, + action: { + if showSuccessView { + dismiss() + resetSelections() + showSuccessView = false + } else { + sendAction() + } + } + ) + .disabled(isSendButtonDisabled) + } + } + } + .onAppear { + flagCategories = FlagOption.createFlagCategories(for: flagTarget) + } + } + + private func resetSelections() { + selectedFlagOption = nil + selectedSendOption = nil + selectedVisibilityOption = nil + } + + private var categoryView: some View { + ScrollView { + VStack(spacing: 30) { + FlagOptionPicker( + previousSelection: .constant(nil), + currentSelection: $selectedFlagOption, + options: flagCategories, + title: String(localized: .localizable.flagUserCategoryTitle), + subtitle: String(localized: .localizable.flagUserCategoryDescription) + ) + + if selectedFlagOption != nil { + FlagOptionPicker( + previousSelection: $selectedFlagOption, + currentSelection: $selectedSendOption, + options: FlagOption.flagUserSendOptions, + title: String(localized: .localizable.flagSendTitle), + subtitle: nil + ) + .transition(.move(edge: .leading).combined(with: .opacity)) + } + + if selectedSendOption != nil && !isUserMuted { + FlagOptionPicker( + previousSelection: .constant(nil), + currentSelection: $selectedVisibilityOption, + options: FlagOption.flagUserVisibilityOptions, + title: String(localized: .localizable.flagUserMuteCategoryTitle), + subtitle: nil + ) + .transition(.move(edge: .leading).combined(with: .opacity)) + } + } + } + .animation(.easeInOut, value: selectedFlagOption) + .animation(.easeInOut, value: selectedSendOption) + } +} + +#Preview { + struct PreviewWrapper: View { + @State private var selectedFlagOption: FlagOption? + @State private var selectedSendOption: FlagOption? + @State private var selectedVisibilityOption: FlagOption? + @State private var showSuccessView = false + let author = Author() + + var body: some View { + NavigationStack { + UserFlagView( + selectedFlagOption: $selectedFlagOption, + selectedSendOption: $selectedSendOption, + selectedVisibilityOption: $selectedVisibilityOption, + showSuccessView: $showSuccessView, + flagTarget: .author(author), + sendAction: {} + ) + } + .onAppear { + selectedFlagOption = nil + } + .background(Color.appBg) + } + } + return PreviewWrapper() +} diff --git a/Nos/Views/Modifiers/ReportMenuModifier.swift b/Nos/Views/Modifiers/ReportMenuModifier.swift index 2917ed64f..6839a30b9 100644 --- a/Nos/Views/Modifiers/ReportMenuModifier.swift +++ b/Nos/Views/Modifiers/ReportMenuModifier.swift @@ -17,6 +17,7 @@ struct ReportMenuModifier: ViewModifier { @State private var confirmationDialogState: ConfirmationDialogState? @State private var selectedFlagOption: FlagOption? @State private var selectedFlagSendOption: FlagOption? + @State private var selectedVisibilityOption: FlagOption? @State private var showFlagSuccessView = false @Environment(\.managedObjectContext) private var viewContext @@ -55,7 +56,26 @@ struct ReportMenuModifier: ViewModifier { } } case .author: - oldModerationFlow(content: content) + content + .sheet(isPresented: $isPresented) { + NavigationStack { + UserFlagView( + selectedFlagOption: $selectedFlagOption, + selectedSendOption: $selectedFlagSendOption, + selectedVisibilityOption: $selectedVisibilityOption, + showSuccessView: $showFlagSuccessView, + flagTarget: reportedObject, + sendAction: { + let selectCategory = selectedVisibilityOption?.category ?? .visibility(.dontMute) + publishReportForNewModerationFlow(selectCategory) + Task { + await muteUserIfNeeded() + showFlagSuccessView = true + } + } + ) + } + } } } @@ -92,7 +112,9 @@ struct ReportMenuModifier: ViewModifier { actions: { if let author = reportedObject.author { Button(String(localized: .localizable.yes)) { - mute(author: author) + Task { + await mute(author: author) + } } Button(String(localized: .localizable.no)) {} } @@ -173,16 +195,26 @@ struct ReportMenuModifier: ViewModifier { } } - func mute(author: Author) { - Task { - do { - try await author.mute(viewContext: viewContext) - } catch { - Log.error(error.localizedDescription) + func mute(author: Author) async { + do { + try await author.mute(viewContext: viewContext) + } catch { + Log.error(error.localizedDescription) + } + } + + /// Determines if the user being flagged should be muted. + private func muteUserIfNeeded() async { + if let author = reportedObject.author { + guard case .visibility(let visibilityCategory) = selectedVisibilityOption?.category else { return } + + if visibilityCategory == .mute { + guard !author.muted else { return } + await mute(author: author) } } } - + /// An enum to simplify the user selection through the sequence of connected /// dialogs enum UserSelection: Equatable {