diff --git a/Core/Pixel.swift b/Core/Pixel.swift index c2ee1ee14c..9838e5d53e 100644 --- a/Core/Pixel.swift +++ b/Core/Pixel.swift @@ -256,7 +256,8 @@ public class Pixel { headers: headers) let request = APIRequest(configuration: configuration, urlSession: .session(useMainThreadCallbackQueue: true)) request.fetch { _, error in - os_log("Pixel fired %{public}s %{public}s", log: .generalLog, type: .debug, pixelName, "\(params)") + os_log("Pixel fired %{public}s %{public}s", log: .generalLog, type: .debug, + pixelName.replacingOccurrences(of: "_", with: "."), "\(params)") onComplete(error) } } diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 4772c79867..0ee619340d 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -779,6 +779,17 @@ extension Pixel { case duckPlayerSettingNeverOverlayYoutube case duckPlayerContingencySettingsDisplayed case duckPlayerContingencyLearnMoreClicked + + // MARK: Unified Feedback Form + case pproFeedbackFeatureRequest(description: String, source: String) + case pproFeedbackGeneralFeedback(description: String, source: String) + case pproFeedbackReportIssue(source: String, category: String, subcategory: String, description: String, metadata: String) + case pproFeedbackFormShow + case pproFeedbackActionsScreenShow(source: String) + case pproFeedbackCategoryScreenShow(source: String, reportType: String) + case pproFeedbackSubcategoryScreenShow(source: String, reportType: String, category: String) + case pproFeedbackSubmitScreenShow(source: String, reportType: String, category: String, subcategory: String) + case pproFeedbackSubmitScreenFAQClick(source: String, reportType: String, category: String, subcategory: String) } } @@ -1551,6 +1562,17 @@ extension Pixel.Event { case .duckPlayerSettingNeverOverlayYoutube: return "duckplayer_setting_never_overlay_youtube" case .duckPlayerContingencySettingsDisplayed: return "duckplayer_ios_contingency_settings-displayed" case .duckPlayerContingencyLearnMoreClicked: return "duckplayer_ios_contingency_learn-more-clicked" + + // MARK: Unified Feedback Form + case .pproFeedbackFeatureRequest: return "m_ppro_feedback_feature-request" + case .pproFeedbackGeneralFeedback: return "m_ppro_feedback_general-feedback" + case .pproFeedbackReportIssue: return "m_ppro_feedback_report-issue" + case .pproFeedbackFormShow: return "m_ppro_feedback_general-screen_show" + case .pproFeedbackActionsScreenShow: return "m_ppro_feedback_actions-screen_show" + case .pproFeedbackCategoryScreenShow: return "m_ppro_feedback_category-screen_show" + case .pproFeedbackSubcategoryScreenShow: return "m_ppro_feedback_subcategory-screen_show" + case .pproFeedbackSubmitScreenShow: return "m_ppro_feedback_submit-screen_show" + case .pproFeedbackSubmitScreenFAQClick: return "m_ppro_feedback_submit-screen-faq_click" } } } diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index fb4f96cf42..c9919dd4b2 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -785,6 +785,7 @@ B6BA95E828924730004ABA20 /* JSAlertController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B6BA95E728924730004ABA20 /* JSAlertController.storyboard */; }; B6CB93E5286445AB0090FEB4 /* Base64DownloadSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CB93E4286445AB0090FEB4 /* Base64DownloadSession.swift */; }; BBFF18B12C76448100C48D7D /* QuerySubmittedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFF18B02C76448100C48D7D /* QuerySubmittedTests.swift */; }; + BD10B8AA2C7629740033115D /* Logger+Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD10B8A92C7629740033115D /* Logger+Subscription.swift */; }; BD15DB852B959CFD00821457 /* BundleExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD15DB842B959CFD00821457 /* BundleExtension.swift */; }; BD2F39EB2C19F955005B19E7 /* NetworkProtectionDNSSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD2F39EA2C19F955005B19E7 /* NetworkProtectionDNSSettingsView.swift */; }; BD862E032B30DA170073E2EE /* VPNFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD862E022B30DA170073E2EE /* VPNFeedbackFormViewModel.swift */; }; @@ -796,6 +797,12 @@ BDD3B3552B8EF8DB005857A8 /* NetworkProtectionUNNotificationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE3766DD2AC5945500AAB575 /* NetworkProtectionUNNotificationPresenter.swift */; }; BDE219E62C406D19005D5884 /* PrivacyProDataReporting.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDE219E52C406D19005D5884 /* PrivacyProDataReporting.swift */; }; BDE219EA2C457B46005D5884 /* PrivacyProDataReporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDE219E92C457B46005D5884 /* PrivacyProDataReporterTests.swift */; }; + BDE91CD62C6294020005CB74 /* FeedbackCategoryProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDE91CD52C6294020005CB74 /* FeedbackCategoryProviding.swift */; }; + BDE91CD82C629A910005CB74 /* UnifiedFeedbackSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDE91CD72C629A910005CB74 /* UnifiedFeedbackSender.swift */; }; + BDE91CDA2C62A70B0005CB74 /* UnifiedMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDE91CD92C62A70B0005CB74 /* UnifiedMetadataCollector.swift */; }; + BDE91CDC2C62AA3A0005CB74 /* DefaultMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDE91CDB2C62AA3A0005CB74 /* DefaultMetadataCollector.swift */; }; + BDE91CDE2C62B90F0005CB74 /* UnifiedFeedbackRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDE91CDD2C62B90F0005CB74 /* UnifiedFeedbackRootView.swift */; }; + BDE91CE02C6515420005CB74 /* UnifiedFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDE91CDF2C6515410005CB74 /* UnifiedFeedbackFormViewModel.swift */; }; BDF8D0022C1B87F4003E3B27 /* NetworkProtectionDNSSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDF8D0012C1B87F4003E3B27 /* NetworkProtectionDNSSettingsViewModel.swift */; }; BDFF031D2BA3D2BD00F324C9 /* DefaultNetworkProtectionVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFF031C2BA3D2BD00F324C9 /* DefaultNetworkProtectionVisibility.swift */; }; BDFF03212BA3D3CF00F324C9 /* NetworkProtectionVisibilityForTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFF03202BA3D3CF00F324C9 /* NetworkProtectionVisibilityForTunnelProvider.swift */; }; @@ -2534,6 +2541,7 @@ B6DFE6CF2BC7E47500A9CE59 /* SwiftLintTool.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftLintTool.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; B6DFE6D92BC7E61B00A9CE59 /* SwiftLintToolBundleConfiguration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SwiftLintToolBundleConfiguration.xcconfig; sourceTree = ""; }; BBFF18B02C76448100C48D7D /* QuerySubmittedTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuerySubmittedTests.swift; sourceTree = ""; }; + BD10B8A92C7629740033115D /* Logger+Subscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Logger+Subscription.swift"; sourceTree = ""; }; BD15DB842B959CFD00821457 /* BundleExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleExtension.swift; sourceTree = ""; }; BD2F39EA2C19F955005B19E7 /* NetworkProtectionDNSSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionDNSSettingsView.swift; sourceTree = ""; }; BD862E022B30DA170073E2EE /* VPNFeedbackFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewModel.swift; sourceTree = ""; }; @@ -2544,6 +2552,12 @@ BDC234F62B27F51100D3C798 /* UniquePixel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniquePixel.swift; sourceTree = ""; }; BDE219E52C406D19005D5884 /* PrivacyProDataReporting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyProDataReporting.swift; sourceTree = ""; }; BDE219E92C457B46005D5884 /* PrivacyProDataReporterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrivacyProDataReporterTests.swift; sourceTree = ""; }; + BDE91CD52C6294020005CB74 /* FeedbackCategoryProviding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackCategoryProviding.swift; sourceTree = ""; }; + BDE91CD72C629A910005CB74 /* UnifiedFeedbackSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedFeedbackSender.swift; sourceTree = ""; }; + BDE91CD92C62A70B0005CB74 /* UnifiedMetadataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedMetadataCollector.swift; sourceTree = ""; }; + BDE91CDB2C62AA3A0005CB74 /* DefaultMetadataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultMetadataCollector.swift; sourceTree = ""; }; + BDE91CDD2C62B90F0005CB74 /* UnifiedFeedbackRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedFeedbackRootView.swift; sourceTree = ""; }; + BDE91CDF2C6515410005CB74 /* UnifiedFeedbackFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedFeedbackFormViewModel.swift; sourceTree = ""; }; BDF8D0012C1B87F4003E3B27 /* NetworkProtectionDNSSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionDNSSettingsViewModel.swift; sourceTree = ""; }; BDFF03192BA39C5A00F324C9 /* NetworkProtectionFeatureVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionFeatureVisibility.swift; sourceTree = ""; }; BDFF031C2BA3D2BD00F324C9 /* DefaultNetworkProtectionVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultNetworkProtectionVisibility.swift; sourceTree = ""; }; @@ -4828,6 +4842,19 @@ path = Feedback; sourceTree = ""; }; + BDE91CD42C6292BF0005CB74 /* Feedback */ = { + isa = PBXGroup; + children = ( + BDE91CD52C6294020005CB74 /* FeedbackCategoryProviding.swift */, + BDE91CD72C629A910005CB74 /* UnifiedFeedbackSender.swift */, + BDE91CD92C62A70B0005CB74 /* UnifiedMetadataCollector.swift */, + BDE91CDB2C62AA3A0005CB74 /* DefaultMetadataCollector.swift */, + BDE91CDD2C62B90F0005CB74 /* UnifiedFeedbackRootView.swift */, + BDE91CDF2C6515410005CB74 /* UnifiedFeedbackFormViewModel.swift */, + ); + path = Feedback; + sourceTree = ""; + }; BDFF031F2BA3D3AD00F324C9 /* Feature Visibility */ = { isa = PBXGroup; children = ( @@ -5049,6 +5076,7 @@ D664C7922B289AA000CBFA76 /* Subscription */ = { isa = PBXGroup; children = ( + BDE91CD42C6292BF0005CB74 /* Feedback */, F1FDC92F2BF4E0B3006B1435 /* SubscriptionEnvironment+Default.swift */, D60170BB2BA32DD6001911B5 /* Subscription.swift */, D6D95CE42B6DA3F200960317 /* AsyncHeadlessWebview */, @@ -5081,6 +5109,7 @@ D664C7962B289AA000CBFA76 /* Extensions */ = { isa = PBXGroup; children = ( + BD10B8A92C7629740033115D /* Logger+Subscription.swift */, F1FDC9342BF51E41006B1435 /* VPNSettings+Environment.swift */, D664C7982B289AA000CBFA76 /* WKUserContentController+Handler.swift */, ); @@ -7005,6 +7034,7 @@ files = ( EE4FB1862A28CE7200E5CBA7 /* NetworkProtectionStatusView.swift in Sources */, C17B59592A03AAD30055F2D1 /* PasswordGenerationPromptViewModel.swift in Sources */, + BDE91CDE2C62B90F0005CB74 /* UnifiedFeedbackRootView.swift in Sources */, D65625A12C232F5E006EF297 /* SettingsDuckPlayerView.swift in Sources */, D6FEB8B52B74994000C3615F /* HeadlessWebViewCoordinator.swift in Sources */, 6FE1273D2C204C2500EB5724 /* FavoritesView.swift in Sources */, @@ -7140,6 +7170,7 @@ D65625952C22D382006EF297 /* TabViewController.swift in Sources */, 8C4838B5221C8F7F008A6739 /* GestureToolbarButton.swift in Sources */, 310ECFDD282A8BB0005029B3 /* EnableAutofillSettingsTableViewCell.swift in Sources */, + BDE91CD62C6294020005CB74 /* FeedbackCategoryProviding.swift in Sources */, 6F9FFE2A2C57ADB100A238BE /* EditableShortcutsView.swift in Sources */, 1E908BF329827C480008C8F3 /* AutoconsentManagement.swift in Sources */, D6D95CE32B6D9F8800960317 /* AsyncHeadlessWebViewModel.swift in Sources */, @@ -7228,6 +7259,7 @@ 85F2FFCF2211F8E5006BB258 /* TabSwitcherViewController+KeyCommands.swift in Sources */, 3157B43327F497E90042D3D7 /* SaveLoginView.swift in Sources */, F17922E01E71BB59006E3D97 /* AutocompleteViewControllerDelegate.swift in Sources */, + BDE91CDC2C62AA3A0005CB74 /* DefaultMetadataCollector.swift in Sources */, D664C7C82B289AA200CBFA76 /* SubscriptionFlowView.swift in Sources */, EE458D142ABB652900FC651A /* NetworkProtectionDebugUtilities.swift in Sources */, 8528AE7C212EF4A200D0BD74 /* AppRatingPrompt.swift in Sources */, @@ -7325,6 +7357,7 @@ F13B4BC01F180D8A00814661 /* TabsModel.swift in Sources */, BD862E052B30DB250073E2EE /* VPNFeedbackCategory.swift in Sources */, 85AE6690209724120014CF04 /* NotificationView.swift in Sources */, + BDE91CE02C6515420005CB74 /* UnifiedFeedbackFormViewModel.swift in Sources */, 1EA51376286596A000493C6A /* PrivacyIconLogic.swift in Sources */, 980891A92238504B00313A70 /* UILabelExtension.swift in Sources */, 6FD8E51E2C5B84DE00345670 /* NewTabPageIntroMessageView.swift in Sources */, @@ -7363,6 +7396,7 @@ 85C861E628FF1B5F00189466 /* HomeViewSectionRenderersExtension.swift in Sources */, CB825C922C071B1400BCC586 /* AlertView.swift in Sources */, 1DDF40292BA04FCD006850D9 /* SettingsPrivacyProtectionsView.swift in Sources */, + BDE91CD82C629A910005CB74 /* UnifiedFeedbackSender.swift in Sources */, 6F64AA5F2C49463C00CF4489 /* ShortcutsModel.swift in Sources */, F1D477C61F2126CC0031ED49 /* OmniBarState.swift in Sources */, 85F2FFCD2211F615006BB258 /* MainViewController+KeyCommands.swift in Sources */, @@ -7461,6 +7495,7 @@ 85F98F92296F32BD00742F4A /* SyncSettingsViewController.swift in Sources */, 84E341961E2F7EFB00BDBA6F /* AppDelegate.swift in Sources */, 310D091D2799F57200DC0060 /* Download.swift in Sources */, + BDE91CDA2C62A70B0005CB74 /* UnifiedMetadataCollector.swift in Sources */, C13F3F6C2B7F88470083BE40 /* AuthConfirmationPromptViewModel.swift in Sources */, 1EEF124E2850EADE003DDE57 /* PrivacyIconView.swift in Sources */, 9FB027122C2526DD009EA190 /* OnboardingView+IntroDialogContent.swift in Sources */, @@ -7532,6 +7567,7 @@ D664C7B92B289AA200CBFA76 /* WKUserContentController+Handler.swift in Sources */, 1E8AD1D727C2E24E00ABA377 /* DownloadsListRowViewModel.swift in Sources */, 9FEA222E2C324ECD006B03BF /* ViewVisibility.swift in Sources */, + BD10B8AA2C7629740033115D /* Logger+Subscription.swift in Sources */, 1E865AF0272042DB001C74F3 /* TextSizeSettingsViewController.swift in Sources */, D6E0C1892B7A2E0D00D5E1E9 /* DesktopDownloadViewModel.swift in Sources */, 8524CC9A246DA81700E59D45 /* FullscreenDaxDialogViewController.swift in Sources */, diff --git a/DuckDuckGo/Feedback/VPNFeedbackFormViewModel.swift b/DuckDuckGo/Feedback/VPNFeedbackFormViewModel.swift index 7f79dd674e..5efcd86a22 100644 --- a/DuckDuckGo/Feedback/VPNFeedbackFormViewModel.swift +++ b/DuckDuckGo/Feedback/VPNFeedbackFormViewModel.swift @@ -72,7 +72,7 @@ final class VPNFeedbackFormViewModel: ObservableObject { viewState = .feedbackSending do { - let metadata = await metadataCollector.collectMetadata() + let metadata = await metadataCollector.collectVPNMetadata() try await feedbackSender.send(metadata: metadata, category: category, userText: feedbackFormText) viewState = .feedbackSent return true diff --git a/DuckDuckGo/Feedback/VPNMetadataCollector.swift b/DuckDuckGo/Feedback/VPNMetadataCollector.swift index 8345ec44b5..a2f6b69669 100644 --- a/DuckDuckGo/Feedback/VPNMetadataCollector.swift +++ b/DuckDuckGo/Feedback/VPNMetadataCollector.swift @@ -93,22 +93,10 @@ struct VPNMetadata: Encodable { return String(data: encodedMetadata, encoding: .utf8) } - - func toBase64() -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - - do { - let encodedMetadata = try encoder.encode(self) - return encodedMetadata.base64EncodedString() - } catch { - return "Failed to encode metadata to JSON, error message: \(error.localizedDescription)" - } - } } protocol VPNMetadataCollector { - func collectMetadata() async -> VPNMetadata + func collectVPNMetadata() async -> VPNMetadata } final class DefaultVPNMetadataCollector: VPNMetadataCollector { @@ -130,7 +118,7 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { self.defaults = defaults } - func collectMetadata() async -> VPNMetadata { + func collectVPNMetadata() async -> VPNMetadata { let appInfoMetadata = collectAppInfoMetadata() let deviceInfoMetadata = collectDeviceInfoMetadata() let networkInfoMetadata = await collectNetworkInformation() @@ -282,3 +270,17 @@ private extension NSError { } } + +// MARK: - Unified feedback form support + +extension VPNMetadata: UnifiedFeedbackMetadata {} + +extension DefaultVPNMetadataCollector: UnifiedMetadataCollector { + convenience init() { + self.init(statusObserver: AppDependencyProvider.shared.connectionObserver) + } + + func collectMetadata() async -> VPNMetadata? { + await collectVPNMetadata() + } +} diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 8f9bc1dc26..37b5ea00b7 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -121,6 +121,7 @@ class MainViewController: UIViewController { private var settingsDeepLinkcancellables = Set() private let tunnelDefaults = UserDefaults.networkProtectionGroupDefaults private var vpnCancellables = Set() + private var feedbackCancellable: AnyCancellable? let privacyProDataReporter: PrivacyProDataReporting @@ -285,6 +286,7 @@ class MainViewController: UIViewController { subscribeToURLInterceptorNotifications() subscribeToSettingsDeeplinkNotifications() subscribeToNetworkProtectionEvents() + subscribeToUnifiedFeedbackNotifications() findInPageView.delegate = self findInPageBottomLayoutConstraint.constant = 0 @@ -1548,6 +1550,19 @@ class MainViewController: UIViewController { nil, .deliverImmediately) } + private func subscribeToUnifiedFeedbackNotifications() { + feedbackCancellable = NotificationCenter.default.publisher(for: .unifiedFeedbackNotification) + .receive(on: DispatchQueue.main) + .sink { _ in + DispatchQueue.main.async { [weak self] in + guard let navigationController = self?.presentedViewController as? UINavigationController else { return } + navigationController.popToRootViewController(animated: true) + ActionMessageView.present(message: UserText.vpnFeedbackFormSubmittedMessage, + presentationLocation: .withoutBottomBar) + } + } + } + private func onNetworkProtectionEntitlementMessagingChange() { if tunnelDefaults.showEntitlementAlert { presentExpiredEntitlementAlert() diff --git a/DuckDuckGo/NetworkProtectionRootView.swift b/DuckDuckGo/NetworkProtectionRootView.swift index 99a8f29244..267be495cf 100644 --- a/DuckDuckGo/NetworkProtectionRootView.swift +++ b/DuckDuckGo/NetworkProtectionRootView.swift @@ -28,12 +28,15 @@ struct NetworkProtectionRootView: View { init() { let accountManager = AppDependencyProvider.shared.subscriptionManager.accountManager + let subscriptionFeatureAvailability = AppDependencyProvider.shared.subscriptionFeatureAvailability let locationListRepository = NetworkProtectionLocationListCompositeRepository(accountManager: accountManager) + let usesUnifiedFeedbackForm = accountManager.isUserAuthenticated && subscriptionFeatureAvailability.usesUnifiedFeedbackForm statusViewModel = NetworkProtectionStatusViewModel(tunnelController: AppDependencyProvider.shared.networkProtectionTunnelController, settings: AppDependencyProvider.shared.vpnSettings, statusObserver: AppDependencyProvider.shared.connectionObserver, serverInfoObserver: AppDependencyProvider.shared.serverInfoObserver, - locationListRepository: locationListRepository) + locationListRepository: locationListRepository, + usesUnifiedFeedbackForm: usesUnifiedFeedbackForm) } var body: some View { diff --git a/DuckDuckGo/NetworkProtectionStatusView.swift b/DuckDuckGo/NetworkProtectionStatusView.swift index 5223aee773..f8508a2482 100644 --- a/DuckDuckGo/NetworkProtectionStatusView.swift +++ b/DuckDuckGo/NetworkProtectionStatusView.swift @@ -228,14 +228,22 @@ struct NetworkProtectionStatusView: View { @ViewBuilder private func about() -> some View { + let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: DefaultVPNMetadataCollector(), source: .vpn) + Section { NavigationLink(UserText.netPVPNSettingsFAQ, destination: LazyView(NetworkProtectionFAQView())) .daxBodyRegular() .foregroundColor(.init(designSystemColor: .textPrimary)) - NavigationLink(UserText.netPVPNSettingsShareFeedback, destination: VPNFeedbackFormCategoryView()) - .daxBodyRegular() - .foregroundColor(.init(designSystemColor: .textPrimary)) + if statusModel.usesUnifiedFeedbackForm { + NavigationLink(UserText.subscriptionFeedback, destination: UnifiedFeedbackRootView(viewModel: viewModel)) + .daxBodyRegular() + .foregroundColor(.init(designSystemColor: .textPrimary)) + } else { + NavigationLink(UserText.netPVPNSettingsShareFeedback, destination: VPNFeedbackFormCategoryView()) + .daxBodyRegular() + .foregroundColor(.init(designSystemColor: .textPrimary)) + } } header: { Text(UserText.vpnAbout).foregroundColor(.init(designSystemColor: .textSecondary)) } diff --git a/DuckDuckGo/NetworkProtectionStatusViewModel.swift b/DuckDuckGo/NetworkProtectionStatusViewModel.swift index 53a444aabf..7e7e5dc51f 100644 --- a/DuckDuckGo/NetworkProtectionStatusViewModel.swift +++ b/DuckDuckGo/NetworkProtectionStatusViewModel.swift @@ -23,6 +23,7 @@ import NetworkProtection import WidgetKit import BrowserServicesKit import Core +import Subscription struct NetworkProtectionLocationStatusModel { enum LocationIcon { @@ -149,17 +150,22 @@ final class NetworkProtectionStatusViewModel: ObservableObject { @Published public var animationsOn: Bool = false + public let usesUnifiedFeedbackForm: Bool + public init(tunnelController: (TunnelController & TunnelSessionProvider), settings: VPNSettings, statusObserver: ConnectionStatusObserver, serverInfoObserver: ConnectionServerInfoObserver, errorObserver: ConnectionErrorObserver = ConnectionErrorObserverThroughSession(), - locationListRepository: NetworkProtectionLocationListRepository) { + locationListRepository: NetworkProtectionLocationListRepository, + usesUnifiedFeedbackForm: Bool) { self.tunnelController = tunnelController self.settings = settings self.statusObserver = statusObserver self.serverInfoObserver = serverInfoObserver self.errorObserver = errorObserver + self.usesUnifiedFeedbackForm = usesUnifiedFeedbackForm + statusMessage = Self.message(for: statusObserver.recentValue) self.headerTitle = Self.titleText(status: statusObserver.recentValue) self.statusImageID = Self.statusImageID(connected: statusObserver.recentValue.isConnected) diff --git a/DuckDuckGo/SettingsOthersView.swift b/DuckDuckGo/SettingsOthersView.swift index 9194cef3c5..96f3cab8d1 100644 --- a/DuckDuckGo/SettingsOthersView.swift +++ b/DuckDuckGo/SettingsOthersView.swift @@ -33,10 +33,36 @@ struct SettingsOthersView: View { } // Share Feedback - SettingsCellView(label: UserText.settingsFeedback, - image: Image("SettingsFeedback"), - action: { viewModel.presentLegacyView(.feedback) }, - isButton: true) + if viewModel.usesUnifiedFeedbackForm { + let formViewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: DefaultVPNMetadataCollector(), source: .settings) + NavigationLink { + UnifiedFeedbackCategoryView(UserText.subscriptionFeedback, sources: UnifiedFeedbackFlowCategory.self, selection: $viewModel.selectedFeedbackFlow) { + if let selectedFeedbackFlow = viewModel.selectedFeedbackFlow { + switch UnifiedFeedbackFlowCategory(rawValue: selectedFeedbackFlow) { + case nil: + EmptyView() + case .browserFeedback: + LegacyFeedbackView() + case .ppro: + UnifiedFeedbackRootView(viewModel: formViewModel) + } + } + } + .onFirstAppear { + Task { + await formViewModel.process(action: .reportShow) + } + } + } label: { + SettingsCellView(label: UserText.subscriptionFeedback, + image: Image("SettingsFeedback")) + } + } else { + SettingsCellView(label: UserText.settingsFeedback, + image: Image("SettingsFeedback"), + action: { viewModel.presentLegacyView(.feedback) }, + isButton: true) + } // DuckDuckGo on Other Platforms SettingsCellView(label: UserText.duckduckgoOnOtherPlatforms, @@ -45,7 +71,25 @@ struct SettingsOthersView: View { webLinkIndicator: true, isButton: true) } + } + +} +private struct LegacyFeedbackView: View { + var body: some View { + LegacyFeedbackViewRepresentable() } +} +// swiftlint:disable force_cast +private struct LegacyFeedbackViewRepresentable: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> some UIViewController { + let storyboard = UIStoryboard(name: "Feedback", bundle: nil) + let navigationController = storyboard.instantiateViewController(withIdentifier: "Feedback") as! UINavigationController + return navigationController.viewControllers.first! + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + } } +// swiftlint:enable force_cast diff --git a/DuckDuckGo/SettingsViewModel.swift b/DuckDuckGo/SettingsViewModel.swift index 0f98bad108..66f6fcf195 100644 --- a/DuckDuckGo/SettingsViewModel.swift +++ b/DuckDuckGo/SettingsViewModel.swift @@ -39,6 +39,7 @@ final class SettingsViewModel: ObservableObject { private var legacyViewProvider: SettingsLegacyViewProvider private lazy var versionProvider: AppVersion = AppVersion.shared private let voiceSearchHelper: VoiceSearchHelperProtocol + private let subscriptionFeatureAvailability: SubscriptionFeatureAvailability private let syncPausedStateManager: any SyncPausedStateManaging var emailManager: EmailManager { EmailManager() } private let historyManager: HistoryManaging @@ -90,6 +91,8 @@ final class SettingsViewModel: ObservableObject { @Published var isInternalUser: Bool = AppDependencyProvider.shared.internalUserDecider.isInternalUser + @Published var selectedFeedbackFlow: String? + // MARK: - Deep linking // Used to automatically navigate to a specific section // immediately after loading the Settings View @@ -335,11 +338,16 @@ final class SettingsViewModel: ObservableObject { var syncStatus: StatusIndicator { legacyViewProvider.syncService.authState != .inactive ? .on : .off } - + + var usesUnifiedFeedbackForm: Bool { + subscriptionManager.accountManager.isUserAuthenticated && subscriptionFeatureAvailability.usesUnifiedFeedbackForm + } + // MARK: Default Init init(state: SettingsState? = nil, legacyViewProvider: SettingsLegacyViewProvider, subscriptionManager: SubscriptionManager, + subscriptionFeatureAvailability: SubscriptionFeatureAvailability = AppDependencyProvider.shared.subscriptionFeatureAvailability, voiceSearchHelper: VoiceSearchHelperProtocol = AppDependencyProvider.shared.voiceSearchHelper, variantManager: VariantManager = AppDependencyProvider.shared.variantManager, deepLink: SettingsDeepLinkSection? = nil, @@ -350,6 +358,7 @@ final class SettingsViewModel: ObservableObject { self.state = SettingsState.defaults self.legacyViewProvider = legacyViewProvider self.subscriptionManager = subscriptionManager + self.subscriptionFeatureAvailability = subscriptionFeatureAvailability self.voiceSearchHelper = voiceSearchHelper self.deepLinkTarget = deepLink self.historyManager = historyManager diff --git a/DuckDuckGo/Subscription/Feedback/DefaultMetadataCollector.swift b/DuckDuckGo/Subscription/Feedback/DefaultMetadataCollector.swift new file mode 100644 index 0000000000..4df0487239 --- /dev/null +++ b/DuckDuckGo/Subscription/Feedback/DefaultMetadataCollector.swift @@ -0,0 +1,31 @@ +// +// DefaultMetadataCollector.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct DefaultFeedbackMetadata: UnifiedFeedbackMetadata {} + +/// Default implementation for Privacy Pro metadata collector +/// Intentionally left blank as we currently don't collect any metadata for PIR and ITR +/// See `DefaultVPNMetadataCollector` for a reference implementation +final class DefaultMetadataCollector: UnifiedMetadataCollector { + func collectMetadata() async -> DefaultFeedbackMetadata? { + nil + } +} diff --git a/DuckDuckGo/Subscription/Feedback/FeedbackCategoryProviding.swift b/DuckDuckGo/Subscription/Feedback/FeedbackCategoryProviding.swift new file mode 100644 index 0000000000..a478ba3dcd --- /dev/null +++ b/DuckDuckGo/Subscription/Feedback/FeedbackCategoryProviding.swift @@ -0,0 +1,182 @@ +// +// FeedbackCategoryProviding.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol FeedbackCategoryProviding: Hashable, CaseIterable, Identifiable, RawRepresentable { + var displayName: String { get } +} + +protocol FeedbackFAQProviding { + var url: URL { get } +} + +extension FeedbackCategoryProviding where RawValue == String { + var id: String { + rawValue + } +} + +enum UnifiedFeedbackFlowCategory: String, FeedbackCategoryProviding { + case browserFeedback + case ppro + + var displayName: String { + switch self { + case .browserFeedback: return UserText.settingsBrowserFeedback + case .ppro: return UserText.subscriptionTitle + } + } +} + +enum UnifiedFeedbackReportType: String, FeedbackCategoryProviding { + case reportIssue + case requestFeature + case general + + var displayName: String { + switch self { + case .reportIssue: return UserText.pproFeedbackFormReportProblemTitle + case .requestFeature: return UserText.pproFeedbackFormRequestFeatureTitle + case .general: return UserText.pproFeedbackFormGeneralFeedbackTitle + } + } +} + +enum UnifiedFeedbackCategory: String, FeedbackCategoryProviding { + case subscription + case vpn + case pir + case itr + + var displayName: String { + switch self { + case .subscription: return UserText.generalFeedbackFormCategoryPPro + case .vpn: return UserText.generalFeedbackFormCategoryVPN + case .pir: return UserText.generalFeedbackFormCategoryPIR + case .itr: return UserText.generalFeedbackFormCategoryITR + } + } +} + +enum PrivacyProFeedbackSubcategory: String, FeedbackCategoryProviding, FeedbackFAQProviding { + case otp + case somethingElse + + var displayName: String { + switch self { + case .otp: return UserText.pproFeedbackFormCategoryOTP + case .somethingElse: return UserText.pproFeedbackFormCategoryOther + } + } + + var url: URL { + switch self { + case .otp: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/payments/")! + case .somethingElse: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/payments/")! + } + } +} + +enum VPNFeedbackSubcategory: String, FeedbackCategoryProviding, FeedbackFAQProviding { + case unableToInstall + case failsToConnect + case tooSlow + case issueWithAppOrWebsite + case appCrashesOrFreezes + case cantConnectToLocalDevice + case somethingElse + + var displayName: String { + switch self { + case .unableToInstall: return UserText.vpnFeedbackFormCategoryUnableToInstall + case .failsToConnect: return UserText.vpnFeedbackFormCategoryFailsToConnect + case .tooSlow: return UserText.vpnFeedbackFormCategoryTooSlow + case .issueWithAppOrWebsite: return UserText.vpnFeedbackFormCategoryIssuesWithApps + case .appCrashesOrFreezes: return UserText.vpnFeedbackFormCategoryBrowserCrashOrFreeze + case .cantConnectToLocalDevice: return UserText.vpnFeedbackFormCategoryLocalDeviceConnectivity + case .somethingElse: return UserText.vpnFeedbackFormCategoryOther + } + } + + var url: URL { + switch self { + case .unableToInstall: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/vpn/troubleshooting/")! + case .failsToConnect: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/vpn/troubleshooting/")! + case .tooSlow: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/vpn/troubleshooting/")! + case .issueWithAppOrWebsite: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/vpn/troubleshooting/")! + case .appCrashesOrFreezes: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/vpn/troubleshooting/")! + case .cantConnectToLocalDevice: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/vpn/troubleshooting/")! + case .somethingElse: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/vpn/")! + } + } +} + +enum PIRFeedbackSubcategory: String, FeedbackCategoryProviding, FeedbackFAQProviding { + case nothingOnSpecificSite + case notMe + case scanStuck + case removalStuck + case somethingElse + + var displayName: String { + switch self { + case .nothingOnSpecificSite: return UserText.pirFeedbackFormCategoryNothingOnSpecificSite + case .notMe: return UserText.pirFeedbackFormCategoryNotMe + case .scanStuck: return UserText.pirFeedbackFormCategoryScanStuck + case .removalStuck: return UserText.pirFeedbackFormCategoryRemovalStuck + case .somethingElse: return UserText.pirFeedbackFormCategoryOther + } + } + + var url: URL { + switch self { + case .nothingOnSpecificSite: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/personal-information-removal/removal-process/")! + case .notMe: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/personal-information-removal/removal-process/")! + case .scanStuck: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/personal-information-removal/removal-process/")! + case .removalStuck: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/personal-information-removal/removal-process/")! + case .somethingElse: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/personal-information-removal/")! + } + } +} + +enum ITRFeedbackSubcategory: String, FeedbackCategoryProviding, FeedbackFAQProviding { + case accessCode + case cantContactAdvisor + case advisorUnhelpful + case somethingElse + + var displayName: String { + switch self { + case .accessCode: return UserText.itrFeedbackFormCategoryAccessCode + case .cantContactAdvisor: return UserText.itrFeedbackFormCategoryCantContactAdvisor + case .advisorUnhelpful: return UserText.itrFeedbackFormCategoryUnhelpful + case .somethingElse: return UserText.itrFeedbackFormCategorySomethingElse + } + } + + var url: URL { + switch self { + case .accessCode: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/identity-theft-restoration/")! + case .cantContactAdvisor: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/identity-theft-restoration/iris/")! + case .advisorUnhelpful: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/identity-theft-restoration/")! + case .somethingElse: return URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/identity-theft-restoration/")! + } + } +} diff --git a/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackFormViewModel.swift b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackFormViewModel.swift new file mode 100644 index 0000000000..6ecfd2aa63 --- /dev/null +++ b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackFormViewModel.swift @@ -0,0 +1,228 @@ +// +// UnifiedFeedbackFormViewModel.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import SwiftUI + +final class UnifiedFeedbackFormViewModel: ObservableObject { + enum Source: String { + case settings + case ppro + case vpn + case pir + case itr + case unknown + } + + enum ViewState { + case feedbackPending + case feedbackSending + case feedbackSendingFailed + case feedbackSent + case feedbackCanceled + + var canSubmit: Bool { + switch self { + case .feedbackPending: return true + case .feedbackSending: return false + case .feedbackSendingFailed: return true + case .feedbackSent: return false + case .feedbackCanceled: return false + } + } + } + + enum ViewAction { + case submit + case faqClick + case reportShow + case reportActions + case reportCategory + case reportSubcategory + case reportFAQClick + case reportSubmitShow + } + + @Published var viewState: ViewState { + didSet { + updateSubmitButtonStatus() + } + } + + @Published var feedbackFormText: String = "" { + didSet { + updateSubmitButtonStatus() + } + } + + @Published private(set) var submitButtonEnabled: Bool = false + @Published var selectedReportType: String? { + didSet { + selectedCategory = "" + } + } + @Published var selectedCategory: String? { + didSet { + selectedSubcategory = "" + } + } + @Published var selectedSubcategory: String? { + didSet { + feedbackFormText = "" + } + } + + var usesCompactForm: Bool { + guard let selectedReportType else { return false } + switch UnifiedFeedbackReportType(rawValue: selectedReportType) { + case .reportIssue: + return false + default: + return true + } + } + + private let vpnMetadataCollector: any UnifiedMetadataCollector + private let defaultMetadataCollector: any UnifiedMetadataCollector + private let feedbackSender: any UnifiedFeedbackSender + + let source: String + + init(vpnMetadataCollector: any UnifiedMetadataCollector, + defaultMetadatCollector: any UnifiedMetadataCollector = DefaultMetadataCollector(), + feedbackSender: any UnifiedFeedbackSender = DefaultFeedbackSender(), + source: Source = .unknown) { + self.viewState = .feedbackPending + + self.vpnMetadataCollector = vpnMetadataCollector + self.defaultMetadataCollector = defaultMetadatCollector + self.feedbackSender = feedbackSender + self.source = source.rawValue + } + + @MainActor + func process(action: ViewAction) async { + switch action { + case .submit: + self.viewState = .feedbackSending + + do { + try await sendFeedback() + self.viewState = .feedbackSent + } catch { + self.viewState = .feedbackSendingFailed + } + + NotificationCenter.default.post(name: .unifiedFeedbackNotification, object: nil) + case .faqClick: + await openFAQ() + case .reportShow: + await feedbackSender.sendFormShowPixel() + case .reportActions: + await feedbackSender.sendActionsScreenShowPixel(source: source) + case .reportCategory: + if let selectedReportType { + await feedbackSender.sendCategoryScreenShow(source: source, + reportType: selectedReportType) + } + case .reportSubcategory: + if let selectedReportType, let selectedCategory { + await feedbackSender.sendSubcategoryScreenShow(source: source, + reportType: selectedReportType, + category: selectedCategory) + } + case .reportFAQClick: + if let selectedReportType, let selectedCategory, let selectedSubcategory { + await feedbackSender.sendSubmitScreenFAQClickPixel(source: source, + reportType: selectedReportType, + category: selectedCategory, + subcategory: selectedSubcategory) + } + case .reportSubmitShow: + if let selectedReportType, let selectedCategory, let selectedSubcategory { + await feedbackSender.sendSubmitScreenShowPixel(source: source, + reportType: selectedReportType, + category: selectedCategory, + subcategory: selectedSubcategory) + } + } + } + + private func openFAQ() async { + guard let selectedReportType, UnifiedFeedbackReportType(rawValue: selectedReportType) == .reportIssue, + let selectedCategory, let category = UnifiedFeedbackCategory(rawValue: selectedCategory), + let selectedSubcategory else { + return + } + + let url: URL? = { + switch category { + case .subscription: return PrivacyProFeedbackSubcategory(rawValue: selectedSubcategory)?.url + case .vpn: return VPNFeedbackSubcategory(rawValue: selectedSubcategory)?.url + case .pir: return PIRFeedbackSubcategory(rawValue: selectedSubcategory)?.url + case .itr: return ITRFeedbackSubcategory(rawValue: selectedSubcategory)?.url + } + }() + + if let url { + await UIApplication.shared.open(url) + } + } + + private func sendFeedback() async throws { + guard let selectedReportType else { return } + switch UnifiedFeedbackReportType(rawValue: selectedReportType) { + case nil: + return + case .requestFeature: + try await feedbackSender.sendFeatureRequestPixel(description: feedbackFormText, + source: source) + case .general: + try await feedbackSender.sendGeneralFeedbackPixel(description: feedbackFormText, + source: source) + case .reportIssue: + try await reportProblem() + } + } + + private func reportProblem() async throws { + guard let selectedCategory, let selectedSubcategory else { return } + switch UnifiedFeedbackCategory(rawValue: selectedCategory) { + case .vpn: + let metadata = await vpnMetadataCollector.collectMetadata() + try await feedbackSender.sendReportIssuePixel(source: source, + category: selectedCategory, + subcategory: selectedSubcategory, + description: feedbackFormText, + metadata: metadata as? VPNMetadata) + default: + let metadata = await defaultMetadataCollector.collectMetadata() + try await feedbackSender.sendReportIssuePixel(source: source, + category: selectedCategory, + subcategory: selectedSubcategory, + description: feedbackFormText, + metadata: metadata as? DefaultFeedbackMetadata) + } + } + + private func updateSubmitButtonStatus() { + self.submitButtonEnabled = viewState.canSubmit && !feedbackFormText.isEmpty + } + +} diff --git a/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackRootView.swift b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackRootView.swift new file mode 100644 index 0000000000..58b43f15d7 --- /dev/null +++ b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackRootView.swift @@ -0,0 +1,399 @@ +// +// UnifiedFeedbackRootView.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import NetworkProtection + +struct UnifiedFeedbackRootView: View { + @StateObject var viewModel: UnifiedFeedbackFormViewModel + + var body: some View { + UnifiedFeedbackCategoryView(UserText.pproFeedbackFormTitle, sources: UnifiedFeedbackReportType.self, selection: $viewModel.selectedReportType) { + if let selectedReportType = viewModel.selectedReportType { + switch UnifiedFeedbackReportType(rawValue: selectedReportType) { + case nil: + EmptyView() + case .general: + CompactIssueDescriptionFormView(viewModel: viewModel, + navigationTitle: UserText.pproFeedbackFormGeneralFeedbackTitle, + label: UserText.pproFeedbackFormGeneralFeedbackTitle, + placeholder: UserText.pproFeedbackFormGeneralFeedbackPlaceholder) + case .requestFeature: + CompactIssueDescriptionFormView(viewModel: viewModel, + navigationTitle: UserText.pproFeedbackFormRequestFeatureTitle, + label: UserText.pproFeedbackFormRequestFeatureTitle, + placeholder: UserText.pproFeedbackFormRequestFeaturePlaceholder) + case .reportIssue: + reportProblemView() + } + } + } + .onFirstAppear { + Task { + await viewModel.process(action: .reportActions) + } + } + } + + @ViewBuilder + func reportProblemView() -> some View { + UnifiedFeedbackCategoryView(UserText.pproFeedbackFormReportProblemTitle, + sources: UnifiedFeedbackCategory.self, + selection: $viewModel.selectedCategory) { + Group { + if let selectedCategory = viewModel.selectedCategory { + switch UnifiedFeedbackCategory(rawValue: selectedCategory) { + case nil: + EmptyView() + case .subscription: + UnifiedFeedbackCategoryView(UserText.pproFeedbackFormReportPProProblemTitle, + sources: PrivacyProFeedbackSubcategory.self, + selection: $viewModel.selectedSubcategory) { + IssueDescriptionFormView(viewModel: viewModel, + placeholder: UserText.pproFeedbackFormReportProblemPlaceholder) + } + case .vpn: + UnifiedFeedbackCategoryView(UserText.pproFeedbackFormReportVPNProblemTitle, + sources: VPNFeedbackSubcategory.self, + selection: $viewModel.selectedSubcategory) { + IssueDescriptionFormView(viewModel: viewModel, + placeholder: UserText.pproFeedbackFormReportProblemPlaceholder) + } + case .pir: + UnifiedFeedbackCategoryView(UserText.pproFeedbackFormReportPIRProblemTitle, + sources: PIRFeedbackSubcategory.self, + selection: $viewModel.selectedSubcategory) { + IssueDescriptionFormView(viewModel: viewModel, + placeholder: UserText.pproFeedbackFormReportProblemPlaceholder) + } + case .itr: + UnifiedFeedbackCategoryView(UserText.pproFeedbackFormReportITRProblemTitle, + sources: ITRFeedbackSubcategory.self, + selection: $viewModel.selectedSubcategory) { + IssueDescriptionFormView(viewModel: viewModel, + placeholder: UserText.pproFeedbackFormReportProblemPlaceholder) + } + } + } + } + .onFirstAppear { + Task { + await viewModel.process(action: .reportSubcategory) + } + } + } + .onFirstAppear { + Task { + await viewModel.process(action: .reportCategory) + } + } + } +} + +struct UnifiedFeedbackCategoryView: View where Category.AllCases == [Category], Category.RawValue == String { + let title: String + let prompt: String + let sources: Category.Type + let selection: Binding + let destination: () -> Destination + + init(_ title: String, + prompt: String = UserText.pproFeedbackFormSelectCategoryTitle, + sources: Category.Type, + selection: Binding, + @ViewBuilder destination: @escaping () -> Destination) { + self.title = title + self.prompt = prompt + self.sources = sources + self.selection = selection + self.destination = destination + } + + var body: some View { + VStack { + List(selection: selection) { + Section { + ForEach(sources.allCases) { option in + NavigationLink { + destination() + } label: { + Text(option.displayName) + .daxBodyRegular() + .foregroundColor(.init(designSystemColor: .textPrimary)) + } + .tag(option.rawValue) + .listRowBackground(Color(designSystemColor: .surface)) + } + } header: { + Text(prompt) + } + } + .listRowBackground(Color(designSystemColor: .surface)) + } + .applyInsetGroupedListStyle() + .navigationTitle(title) + } +} + +private struct CompactIssueDescriptionFormView: View { + @ObservedObject var viewModel: UnifiedFeedbackFormViewModel + @FocusState private var isTextEditorFocused: Bool + + let navigationTitle: String + let label: String + let placeholder: String + + var body: some View { + configuredForm() + .applyBackground() + .navigationTitle(navigationTitle) + .onFirstAppear { + Task { + await viewModel.process(action: .reportSubmitShow) + } + } + } + + @ViewBuilder + private func form() -> some View { + ScrollView { + ScrollViewReader { scrollView in + VStack { + VStack(alignment: .leading, spacing: 10) { + IssueDescriptionTextEditor(label: label, + placeholder: placeholder, + text: $viewModel.feedbackFormText, + focusState: $isTextEditorFocused, + scrollViewProxy: scrollView) + } + .foregroundColor(.secondary) + .background(Color(designSystemColor: .background)) + .padding(16) + .daxFootnoteRegular() + submitButton() + .disabled(!viewModel.submitButtonEnabled) + } + } + } + } + + @ViewBuilder + private func configuredForm() -> some View { + if #available(iOS 16, *) { + form().scrollDismissesKeyboard(.interactively) + } else { + form() + } + } + + @ViewBuilder + private func submitButton() -> some View { + Button { + Task { + _ = await viewModel.process(action: .submit) + } + } label: { + Text(UserText.vpnFeedbackFormButtonSubmit) + } + .buttonStyle(UnifiedFeedbackFormButtonStyle()) + .padding(16) + } +} + +private struct IssueDescriptionFormView: View { + @ObservedObject var viewModel: UnifiedFeedbackFormViewModel + @FocusState private var isTextEditorFocused: Bool + + let placeholder: String + + var body: some View { + configuredForm() + .applyBackground() + .navigationTitle(UserText.pproFeedbackFormReportProblemTitle) + .onFirstAppear { + Task { + await viewModel.process(action: .reportSubmitShow) + } + } + } + + @ViewBuilder + private func form() -> some View { + ScrollView { + ScrollViewReader { scrollView in + VStack { + VStack(alignment: .leading, spacing: 10) { + header() + .padding(.horizontal, 4) + IssueDescriptionTextEditor(label: UserText.pproFeedbackFormTextBoxTitle, + placeholder: placeholder, + text: $viewModel.feedbackFormText, + focusState: $isTextEditorFocused, + scrollViewProxy: scrollView) + footer() + .padding(.horizontal, 4) + } + .foregroundColor(.secondary) + .background(Color(designSystemColor: .background)) + .padding(16) + .daxFootnoteRegular() + submitButton() + .disabled(!viewModel.submitButtonEnabled) + } + } + } + } + + @ViewBuilder + private func configuredForm() -> some View { + if #available(iOS 16, *) { + form().scrollDismissesKeyboard(.interactively) + } else { + form() + } + } + + @ViewBuilder + private func header() -> some View { + Text(LocalizedStringKey(UserText.pproFeedbackFormText1)) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .environment(\.openURL, OpenURLAction { _ in + Task { + await viewModel.process(action: .reportFAQClick) + await viewModel.process(action: .faqClick) + } + return .handled + }) + + Spacer() + .frame(height: 1) + .id(1) + } + + @ViewBuilder + private func footer() -> some View { + Text(UserText.pproFeedbackFormText2) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading) { + Text(UserText.pproFeedbackFormText3) + Text(UserText.pproFeedbackFormText4) + } + + Text(UserText.pproFeedbackFormText5) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + + @ViewBuilder + private func submitButton() -> some View { + Button { + Task { + _ = await viewModel.process(action: .submit) + } + } label: { + Text(UserText.vpnFeedbackFormButtonSubmit) + } + .buttonStyle(UnifiedFeedbackFormButtonStyle()) + .padding(16) + } +} + +private struct IssueDescriptionTextEditor: View { + let label: String + let placeholder: String + let text: Binding + let focusState: FocusState.Binding + let scrollViewProxy: ScrollViewProxy + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.caption) + .textCase(.uppercase) + .foregroundColor(.secondary) + .padding(.horizontal, 4) + TextEditorWithPlaceholder(text: text, placeholder: placeholder) + .font(.body) + .foregroundColor(.primary) + .background(Color(designSystemColor: .panel)) + .clipShape(RoundedRectangle(cornerRadius: 8.0, style: .continuous)) + .frame(height: 100) + .fixedSize(horizontal: false, vertical: true) + .onChange(of: text.wrappedValue) { value in + text.wrappedValue = String(value.prefix(1000)) + } + } + .focused(focusState) + .onChange(of: focusState.wrappedValue) { isFocused in + guard isFocused else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + withAnimation { + scrollViewProxy.scrollTo(1, anchor: .top) + } + } + } + } +} + +private struct UnifiedFeedbackFormButtonStyle: ButtonStyle { + + @Environment(\.isEnabled) private var isEnabled: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundColor(Color.white) + .frame(maxWidth: .infinity) + .padding(.horizontal) + .frame(height: 50) + .background(Color(designSystemColor: .accent)) + .cornerRadius(8) + .daxButton() + .opacity(isEnabled ? 1.0 : 0.4) + + } + +} + +private struct TextEditorWithPlaceholder: View { + let text: Binding + let placeholder: String + + var body: some View { + ZStack(alignment: .topLeading) { + TextEditor(text: text) + if text.wrappedValue.isEmpty { + Text(placeholder) + .foregroundColor(.secondary) + .opacity(0.5) + .padding(.top, 8) + .padding(.leading, 5) + } + } + } +} + +extension NSNotification.Name { + static let unifiedFeedbackNotification: NSNotification.Name = Notification.Name(rawValue: "com.duckduckgo.notification.unifiedFeedback") +} diff --git a/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackSender.swift b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackSender.swift new file mode 100644 index 0000000000..76f7a03590 --- /dev/null +++ b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackSender.swift @@ -0,0 +1,220 @@ +// +// UnifiedFeedbackSender.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Core + +protocol UnifiedFeedbackSender { + func sendFeatureRequestPixel(description: String, source: String) async throws + func sendGeneralFeedbackPixel(description: String, source: String) async throws + func sendReportIssuePixel(source: String, category: String, subcategory: String, description: String, metadata: T?) async throws + + func sendFormShowPixel() async + func sendSubmitScreenShowPixel(source: String, reportType: String, category: String, subcategory: String) async + func sendActionsScreenShowPixel(source: String) async + func sendCategoryScreenShow(source: String, reportType: String) async + func sendSubcategoryScreenShow(source: String, reportType: String, category: String) async + func sendSubmitScreenFAQClickPixel(source: String, reportType: String, category: String, subcategory: String) async + + static func additionalParameters(for pixel: Pixel.Event) -> [String: String] +} + +enum UnifiedFeedbackSenderFrequency { + case regular + case dailyAndCount +} + +extension UnifiedFeedbackSender { + static func additionalParameters(for pixel: Pixel.Event) -> [String: String] { + [:] + } + + func sendPixel(_ pixel: Pixel.Event, frequency: UnifiedFeedbackSenderFrequency) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let completionHandler: (Error?) -> Void = { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + + switch frequency { + case .regular: + Pixel.fire(pixel: pixel, + withAdditionalParameters: Self.additionalParameters(for: pixel), + onComplete: completionHandler) + case .dailyAndCount: + DailyPixel.fireDailyAndCount(pixel: pixel, + withAdditionalParameters: Self.additionalParameters(for: pixel), + onDailyComplete: { _ in }, + onCountComplete: completionHandler) + } + } + } +} + +protocol StringRepresentable: RawRepresentable { + static var `default`: Self { get } +} + +extension StringRepresentable where RawValue == String { + static func from(_ text: String) -> String { + (Self(rawValue: text) ?? .default).rawValue + } +} + +struct DefaultFeedbackSender: UnifiedFeedbackSender { + enum Source: String, StringRepresentable { + case settings, ppro, vpn, pir, itr, unknown + static var `default` = Source.unknown + } + + enum ReportType: String, StringRepresentable { + case general, reportIssue, requestFeature + static var `default` = ReportType.general + } + + enum Category: String, StringRepresentable { + case subscription, vpn, pir, itr, unknown + static var `default` = Category.unknown + } + + enum Subcategory: String, StringRepresentable { + case otp + case unableToInstall, failsToConnect, tooSlow, issueWithAppOrWebsite, appCrashesOrFreezes, cantConnectToLocalDevice + case nothingOnSpecificSite, notMe, scanStuck, removalStuck + case accessCode, cantContactAdvisor, advisorUnhelpful + case somethingElse + static var `default` = Subcategory.somethingElse + } + + func sendFeatureRequestPixel(description: String, source: String) async throws { + try await sendPixel(.pproFeedbackFeatureRequest(description: description, + source: Source.from(source)), + frequency: .regular) + } + + func sendGeneralFeedbackPixel(description: String, source: String) async throws { + try await sendPixel(.pproFeedbackGeneralFeedback(description: description, + source: Source.from(source)), + frequency: .regular) + } + + func sendReportIssuePixel(source: String, category: String, subcategory: String, description: String, metadata: T?) async throws { + try await sendPixel(.pproFeedbackReportIssue(source: Source.from(source), + category: Category.from(category), + subcategory: Subcategory.from(subcategory), + description: description, + metadata: metadata?.toBase64() ?? ""), + frequency: .regular) + } + + func sendFormShowPixel() async { + try? await sendPixel(.pproFeedbackFormShow, frequency: .regular) + } + + func sendSubmitScreenShowPixel(source: String, reportType: String, category: String, subcategory: String) async { + try? await sendPixel(.pproFeedbackSubmitScreenShow(source: source, + reportType: ReportType.from(reportType), + category: Category.from(category), + subcategory: Subcategory.from(subcategory)), + frequency: .dailyAndCount) + } + + func sendActionsScreenShowPixel(source: String) async { + try? await sendPixel(.pproFeedbackActionsScreenShow(source: source), + frequency: .dailyAndCount) + } + + func sendCategoryScreenShow(source: String, reportType: String) async { + try? await sendPixel(.pproFeedbackCategoryScreenShow(source: source, + reportType: ReportType.from(reportType)), + frequency: .dailyAndCount) + } + + func sendSubcategoryScreenShow(source: String, reportType: String, category: String) async { + try? await sendPixel(.pproFeedbackSubcategoryScreenShow(source: source, + reportType: ReportType.from(reportType), + category: Category.from(category)), + frequency: .dailyAndCount) + } + + func sendSubmitScreenFAQClickPixel(source: String, reportType: String, category: String, subcategory: String) async { + try? await sendPixel(.pproFeedbackSubmitScreenShow(source: source, + reportType: ReportType.from(reportType), + category: Category.from(category), + subcategory: Subcategory.from(subcategory)), + frequency: .dailyAndCount) + } + + static func additionalParameters(for pixel: Pixel.Event) -> [String: String] { + switch pixel { + case .pproFeedbackFeatureRequest(let description, let source): + return [ + "description": description, + "source": source, + ] + case .pproFeedbackGeneralFeedback(let description, let source): + return [ + "description": description, + "source": source, + ] + case .pproFeedbackReportIssue(let source, let category, let subcategory, let description, let metadata): + return [ + "description": description, + "source": source, + "category": category, + "subcategory": subcategory, + "customMetadata": metadata, + ] + case .pproFeedbackActionsScreenShow(let source): + return [ + "source": source, + ] + case .pproFeedbackCategoryScreenShow(let source, let reportType): + return [ + "source": source, + "reportType": reportType, + ] + case .pproFeedbackSubcategoryScreenShow(let source, let reportType, let category): + return [ + "source": source, + "reportType": reportType, + "category": category, + ] + case .pproFeedbackSubmitScreenShow(let source, let reportType, let category, let subcategory): + return [ + "source": source, + "reportType": reportType, + "category": category, + "subcategory": subcategory, + ] + case .pproFeedbackSubmitScreenFAQClick(let source, let reportType, let category, let subcategory): + return [ + "source": source, + "reportType": reportType, + "category": category, + "subcategory": subcategory, + ] + default: + return [:] + } + } +} diff --git a/DuckDuckGo/Subscription/Feedback/UnifiedMetadataCollector.swift b/DuckDuckGo/Subscription/Feedback/UnifiedMetadataCollector.swift new file mode 100644 index 0000000000..e6a0c25161 --- /dev/null +++ b/DuckDuckGo/Subscription/Feedback/UnifiedMetadataCollector.swift @@ -0,0 +1,43 @@ +// +// UnifiedMetadataCollector.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol UnifiedMetadataCollector { + associatedtype Metadata: UnifiedFeedbackMetadata + + func collectMetadata() async -> Metadata? +} + +protocol UnifiedFeedbackMetadata: Encodable { + func toBase64() -> String +} + +extension UnifiedFeedbackMetadata { + func toBase64() -> String { + let encoder = JSONEncoder() + + do { + let encodedMetadata = try encoder.encode(self) + return encodedMetadata.base64EncodedString() + } catch { + return "Failed to encode metadata to JSON, error message: \(error.localizedDescription)" + } + } +} diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift index fd1669a311..55d1fdbd25 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift @@ -23,6 +23,7 @@ import StoreKit import Subscription import Core import os.log +import BrowserServicesKit final class SubscriptionSettingsViewModel: ObservableObject { @@ -66,12 +67,15 @@ final class SubscriptionSettingsViewModel: ObservableObject { // Read only View State - Should only be modified from the VM @Published private(set) var state: State - - init(subscriptionManager: SubscriptionManager = AppDependencyProvider.shared.subscriptionManager) { + public let usesUnifiedFeedbackForm: Bool + + init(subscriptionManager: SubscriptionManager = AppDependencyProvider.shared.subscriptionManager, + subscriptionFeatureAvailability: SubscriptionFeatureAvailability = AppDependencyProvider.shared.subscriptionFeatureAvailability) { self.subscriptionManager = subscriptionManager let subscriptionFAQURL = subscriptionManager.url(for: .faq) let learnMoreURL = subscriptionFAQURL.appendingPathComponent("adding-email") self.state = State(faqURL: subscriptionFAQURL, learnMoreURL: learnMoreURL) + self.usesUnifiedFeedbackForm = subscriptionManager.accountManager.isUserAuthenticated && subscriptionFeatureAvailability.usesUnifiedFeedbackForm setupNotificationObservers() } diff --git a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift index 328fd45455..149456cee1 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift @@ -195,18 +195,18 @@ struct SubscriptionSettingsView: View { } @ViewBuilder var helpSection: some View { - Section(header: Text(UserText.subscriptionHelpAndSupport), - footer: Text(UserText.subscriptionFAQFooter)) { - - SettingsCustomCell(content: { - Text(UserText.subscriptionFAQ) - .daxBodyRegular() - .foregroundColor(Color(designSystemColor: .accent)) - }, - action: { viewModel.displayFAQView(true) }, - disclosureIndicator: false, - isButton: true) - + if viewModel.usesUnifiedFeedbackForm { + Section { + faqButton + supportButton + } header: { + Text(UserText.subscriptionHelpAndSupport) + } + } else { + Section(header: Text(UserText.subscriptionHelpAndSupport), + footer: Text(UserText.subscriptionFAQFooter)) { + faqButton + } } } @@ -221,6 +221,27 @@ struct SubscriptionSettingsView: View { disclosureIndicator: false, isButton: true) } + + } + + @ViewBuilder + private var faqButton: some View { + SettingsCustomCell(content: { + Text(UserText.subscriptionFAQ) + .daxBodyRegular() + .foregroundColor(Color(designSystemColor: .accent)) + }, + action: { viewModel.displayFAQView(true) }, + disclosureIndicator: false, + isButton: true) + } + + @ViewBuilder + private var supportButton: some View { + let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: DefaultVPNMetadataCollector(), source: .ppro) + NavigationLink(UserText.subscriptionFeedback, destination: UnifiedFeedbackRootView(viewModel: viewModel)) + .daxBodyRegular() + .foregroundColor(.init(designSystemColor: .textPrimary)) } @ViewBuilder diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index b40d31a65d..c3f08921e0 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -565,6 +565,27 @@ public struct UserText { static let inviteDialogErrorAlertOKButton = NSLocalizedString("invite.alert.ok.button", value: "OK", comment: "OK title for invite screen alert dismissal button") // MARK: - Feedback Form + static let pproFeedbackFormTitle = NSLocalizedString("ppro.feedback-form.title", value: "Send Feedback", comment: "Title for the Privacy Pro feedback form") + static let pproFeedbackFormReportProblemTitle = NSLocalizedString("ppro.feedback-form.report-problem.title", value: "Report a Problem", comment: "Title for the Report a Problem step in the Privacy Pro feedback form") + static let pproFeedbackFormGeneralFeedbackTitle = NSLocalizedString("ppro.feedback-form.general-feedback.title", value: "General Feedback", comment: "Title for the General Feedback step in the Privacy Pro feedback form") + static let pproFeedbackFormRequestFeatureTitle = NSLocalizedString("ppro.feedback-form.request-feature.title", value: "Feature Request", comment: "Title for the Feature Request step in the Privacy Pro feedback form") + static let pproFeedbackFormSelectCategoryTitle = NSLocalizedString("ppro.feedback-form.select-category.title", value: "Select a category", comment: "Title for the category selection section in the Privacy Pro feedback form") + static let pproFeedbackFormTextBoxTitle = NSLocalizedString("ppro.feedback-form.text-box.title", value: "Feedback", comment: "Title for the text box in the Privacy Pro feedback form") + static let pproFeedbackFormReportPProProblemTitle = NSLocalizedString("ppro.feedback-form.report-ppro-problem.title", value: "Subscriptions and Payments", comment: "Title for the Subscriptions and Payments category in the Privacy Pro feedback form") + static let pproFeedbackFormReportVPNProblemTitle = NSLocalizedString("ppro.feedback-form.report-vpn-problem.title", value: "VPN", comment: "Title for the VPN category in the Privacy Pro feedback form") + static let pproFeedbackFormReportPIRProblemTitle = NSLocalizedString("ppro.feedback-form.report-pir-problem.title", value: "Personal Information Removal", comment: "Title for the PIR category in the Privacy Pro feedback form") + static let pproFeedbackFormReportITRProblemTitle = NSLocalizedString("ppro.feedback-form.report-itr-problem.title", value: "Identity Theft Restoration", comment: "Title for the ITR category in the Privacy Pro feedback form") + + static let pproFeedbackFormReportProblemPlaceholder = NSLocalizedString("ppro.feedback-form.report-problem.placeholder", value: "Tell us what's going on…", comment: "Placeholder for the Report a Problem step in the Privacy Pro feedback form") + static let pproFeedbackFormGeneralFeedbackPlaceholder = NSLocalizedString("ppro.feedback-form.general-feedback.placeholder", value: "Please give us your feedback…", comment: "Placeholder for the General Feedback step in the Privacy Pro feedback form") + static let pproFeedbackFormRequestFeaturePlaceholder = NSLocalizedString("ppro.feedback-form.request-feature.placeholder", value: "What feature would you like to see?", comment: "Placeholder for the Feature Request step in the Privacy Pro feedback form") + + static let pproFeedbackFormText1 = NSLocalizedString("ppro.feedback-form.text-1", value: "Found an issue not covered in our [help center](duck://)? We definitely want to know about it.", comment: "Text for the body of the PPro feedback form") + static let pproFeedbackFormText2 = NSLocalizedString("ppro.feedback-form.text-2", value: "In addition to the details entered above, we send some anonymized info with your feedback:", comment: "Text for the body of the PPro feedback form") + static let pproFeedbackFormText3 = NSLocalizedString("ppro.feedback-form.text-3", value: "• Whether some browser features are active", comment: "Bullet text for the body of the PPro feedback form") + static let pproFeedbackFormText4 = NSLocalizedString("ppro.feedback-form.text-4", value: "• Aggregate app diagnostics (e.g., error codes)", comment: "Bullet text for the body of the PPro feedback form") + static let pproFeedbackFormText5 = NSLocalizedString("ppro.feedback-form.text-5", value: "By tapping \"Submit\" you agree that DuckDuckGo may use information submitted to improve the app.", comment: "Text for the body of the PPro feedback form") + static let vpnFeedbackFormTitle = NSLocalizedString("vpn.feedback-form.title", value: "Help Improve the DuckDuckGo VPN", comment: "Title for each screen of the VPN feedback form") static let vpnFeedbackFormCategorySelect = NSLocalizedString("vpn.feedback-form.category.select-category", value: "Select a category", comment: "Title for the category selection state of the VPN feedback form") static let vpnFeedbackFormCategoryUnableToInstall = NSLocalizedString("vpn.feedback-form.category.unable-to-install", value: "Unable to install VPN", comment: "Title for the 'unable to install' category of the VPN feedback form") @@ -598,6 +619,32 @@ public struct UserText { static let vpnAccessRevokedAlertActionSubscribe = NSLocalizedString("vpn.access-revoked.alert.action.subscribe", value: "Subscribe", comment: "Primary action for the alert when the subscription expires") static let vpnAccessRevokedAlertActionCancel = NSLocalizedString("vpn.access-revoked.alert.action.cancel", value: "Dismiss", comment: "Cancel action for the alert when the subscription expires") + // MARK: Unified Feedback Form + static let browserFeedbackReportProblem = NSLocalizedString("send.browser.feedback.report-problem", value: "Report a problem", comment: "Name of the option the user can chose to give browser feedback about a problem they enountered") + static let browserFeedbackRequestFeature = NSLocalizedString("send.browser.feedback.request-feature", value: "Request a feature", comment: "Name of the option the user can chose to give browser feedback about a feature they would like") + static let browserFeedbackGeneralFeedback = NSLocalizedString("send.browser.feedback.general-feedback", value: "General feedback", comment: "Name of the option the user can chose to give general browser feedback") + static let browserFeedbackSelectCategory = NSLocalizedString("send.browser.feedback.select-category", value: "Select a category", comment: "Title of the picker where the user can chose the category of the feedback they want ot send.") + static let feedbackFormTitle = NSLocalizedString("feedback.form.title", value: "Help Improve Privacy Pro", comment: "Title of the feedback form") + static let generalFeedbackFormCategorySelect = NSLocalizedString("feedback.general.category.select", value: "Select a category", comment: "Prompt to select a category for general feedback") + static let generalFeedbackFormCategoryPPro = NSLocalizedString("feedback.general.category.ppro", value: "Subscription and Payments", comment: "Category for subscription and payments feedback") + static let generalFeedbackFormCategoryVPN = NSLocalizedString("feedback.general.category.vpn", value: "VPN", comment: "Category for VPN feedback") + static let generalFeedbackFormCategoryPIR = NSLocalizedString("feedback.general.category.pir", value: "Personal Info Removal", comment: "Category for Personal Info Removal feedback") + static let generalFeedbackFormCategoryITR = NSLocalizedString("feedback.general.category.itr", value: "Identity Theft Restoration", comment: "Category for Identity Theft Restoration feedback") + static let pproFeedbackFormCategorySelect = NSLocalizedString("feedback.ppro.category.select", value: "Select a category", comment: "Prompt to select a category for Privacy Pro feedback") + static let pproFeedbackFormCategoryOTP = NSLocalizedString("feedback.ppro.category.otp", value: "Issue with one-time password", comment: "Category for one-time password issues") + static let pproFeedbackFormCategoryOther = NSLocalizedString("feedback.ppro.category.other", value: "Something else", comment: "Category for other Privacy Pro issues") + static let pirFeedbackFormCategorySelect = NSLocalizedString("feedback.pir.category.select", value: "Select a category", comment: "Prompt to select a category for Personal Info Removal feedback") + static let pirFeedbackFormCategoryNothingOnSpecificSite = NSLocalizedString("feedback.pir.category.nothing-on-site", value: "The scan didn't find my info on a specific site", comment: "Category for when scan doesn't find info on a specific site") + static let pirFeedbackFormCategoryNotMe = NSLocalizedString("feedback.pir.category.not-me", value: "The scan found records which aren't me", comment: "Category for when scan finds incorrect records") + static let pirFeedbackFormCategoryScanStuck = NSLocalizedString("feedback.pir.category.scan-stuck", value: "The scan for records is stuck", comment: "Category for when the scan is stuck") + static let pirFeedbackFormCategoryRemovalStuck = NSLocalizedString("feedback.pir.category.removal-stuck", value: "The removal process is stuck", comment: "Category for when the removal process is stuck") + static let pirFeedbackFormCategoryOther = NSLocalizedString("feedback.pir.category.other", value: "Something else", comment: "Category for other Personal Info Removal issues") + static let itrFeedbackFormCategorySelect = NSLocalizedString("feedback.itr.category.select", value: "Select a category", comment: "Prompt to select a category for Identity Theft Restoration feedback") + static let itrFeedbackFormCategoryAccessCode = NSLocalizedString("feedback.itr.category.access-code", value: "Issue with access code", comment: "Category for access code issues") + static let itrFeedbackFormCategoryCantContactAdvisor = NSLocalizedString("feedback.itr.category.cant-contact-advisor", value: "Unable to contact advisor", comment: "Category for issues contacting an advisor") + static let itrFeedbackFormCategoryUnhelpful = NSLocalizedString("feedback.itr.category.unhelpful", value: "Call to Advisor was unhelpful", comment: "Category for unhelpful advisor calls") + static let itrFeedbackFormCategorySomethingElse = NSLocalizedString("feedback.itr.category.something-else", value: "Something else", comment: "Category for other Identity Theft Restoration issues") + // MARK: VPN Widget public static let vpnSettingsAddWidget = NSLocalizedString("vpn.settings.add.widget", value: "Add VPN Widget to Home Screen", comment: "VPN settings screen cell text for adding the VPN widget to the home screen") @@ -982,6 +1029,7 @@ But if you *do* want a peek under the hood, you can find more information about // Others public static let settingsAboutSection = NSLocalizedString("settings.about.section", value: "About", comment: "Settings section title for About DuckDuckGo") public static let settingsFeedback = NSLocalizedString("settings.feedback", value: "Share Feedback", comment: "Settings cell for Feedback") + public static let settingsBrowserFeedback = NSLocalizedString("settings.browser.feedback", value: "Browser Feedback", comment: "Settings cell for Browser Feedback") public static let duckduckgoOnOtherPlatforms = NSLocalizedString("settings.duckduckgo.on.other.platforms", value: "DuckDuckGo on Other Platforms", comment: "Settings cell to link users to other products by DuckDuckGo") // General Section @@ -1110,6 +1158,7 @@ But if you *do* want a peek under the hood, you can find more information about public static let subscriptionChangePlan = NSLocalizedString("subscription.change.plan", value: "Update Plan or Cancel", comment: "Change plan or cancel title") public static let subscriptionHelpAndSupport = NSLocalizedString("subscription.help", value: "Help and support", comment: "Help and support Section header") public static let subscriptionFAQ = NSLocalizedString("subscription.faq", value: "FAQs and Support", comment: "FAQ Button") + public static let subscriptionFeedback = NSLocalizedString("subscription.feedback", value: "Send Feedback", comment: "Send Feedback Button") public static let subscriptionFAQFooter = NSLocalizedString("subscription.faq.description", value: "Get answers to frequently asked questions or contact Privacy Pro support from our help pages.", comment: "FAQ Description") // Remove subscription confirmation diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index 9d2f5178a8..b38a28e564 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -1241,6 +1241,39 @@ /* Confirmation button */ "feedback.form.submit" = "Submit"; +/* Title of the feedback form */ +"feedback.form.title" = "Help Improve Privacy Pro"; + +/* Category for Identity Theft Restoration feedback */ +"feedback.general.category.itr" = "Identity Theft Restoration"; + +/* Category for Personal Info Removal feedback */ +"feedback.general.category.pir" = "Personal Info Removal"; + +/* Category for subscription and payments feedback */ +"feedback.general.category.ppro" = "Subscription and Payments"; + +/* Prompt to select a category for general feedback */ +"feedback.general.category.select" = "Select a category"; + +/* Category for VPN feedback */ +"feedback.general.category.vpn" = "VPN"; + +/* Category for access code issues */ +"feedback.itr.category.access-code" = "Issue with access code"; + +/* Category for issues contacting an advisor */ +"feedback.itr.category.cant-contact-advisor" = "Unable to contact advisor"; + +/* Prompt to select a category for Identity Theft Restoration feedback */ +"feedback.itr.category.select" = "Select a category"; + +/* Category for other Identity Theft Restoration issues */ +"feedback.itr.category.something-else" = "Something else"; + +/* Category for unhelpful advisor calls */ +"feedback.itr.category.unhelpful" = "Call to Advisor was unhelpful"; + /* No comment provided by engineer. */ "feedback.negative.form.genericPlaceholder" = "Please be as specific as possible"; @@ -1280,6 +1313,24 @@ /* No comment provided by engineer. */ "feedback.performance.slowLoading" = "Web pages or search results load slowly"; +/* Category for when scan finds incorrect records */ +"feedback.pir.category.not-me" = "The scan found records which aren't me"; + +/* Category for when scan doesn't find info on a specific site */ +"feedback.pir.category.nothing-on-site" = "The scan didn't find my info on a specific site"; + +/* Category for other Personal Info Removal issues */ +"feedback.pir.category.other" = "Something else"; + +/* Category for when the removal process is stuck */ +"feedback.pir.category.removal-stuck" = "The removal process is stuck"; + +/* Category for when the scan is stuck */ +"feedback.pir.category.scan-stuck" = "The scan for records is stuck"; + +/* Prompt to select a category for Personal Info Removal feedback */ +"feedback.pir.category.select" = "Select a category"; + /* Header above input field */ "feedback.positive.form.header" = "Share Details"; @@ -1298,6 +1349,15 @@ /* Button encouraging uses to share details aboout their feedback */ "feedback.positive.submit" = "Share Details"; +/* Category for other Privacy Pro issues */ +"feedback.ppro.category.other" = "Something else"; + +/* Category for one-time password issues */ +"feedback.ppro.category.otp" = "Issue with one-time password"; + +/* Prompt to select a category for Privacy Pro feedback */ +"feedback.ppro.category.select" = "Select a category"; + /* No comment provided by engineer. */ "feedback.start.footer" = "Your anonymous feedback is important to us."; @@ -1811,6 +1871,60 @@ https://duckduckgo.com/mac"; /* Deactivate button */ "pm.deactivate" = "Deactivate"; +/* Placeholder for the General Feedback step in the Privacy Pro feedback form */ +"ppro.feedback-form.general-feedback.placeholder" = "Please give us your feedback…"; + +/* Title for the General Feedback step in the Privacy Pro feedback form */ +"ppro.feedback-form.general-feedback.title" = "General Feedback"; + +/* Title for the ITR category in the Privacy Pro feedback form */ +"ppro.feedback-form.report-itr-problem.title" = "Identity Theft Restoration"; + +/* Title for the PIR category in the Privacy Pro feedback form */ +"ppro.feedback-form.report-pir-problem.title" = "Personal Information Removal"; + +/* Title for the Subscriptions and Payments category in the Privacy Pro feedback form */ +"ppro.feedback-form.report-ppro-problem.title" = "Subscriptions and Payments"; + +/* Placeholder for the Report a Problem step in the Privacy Pro feedback form */ +"ppro.feedback-form.report-problem.placeholder" = "Tell us what's going on…"; + +/* Title for the Report a Problem step in the Privacy Pro feedback form */ +"ppro.feedback-form.report-problem.title" = "Report a Problem"; + +/* Title for the VPN category in the Privacy Pro feedback form */ +"ppro.feedback-form.report-vpn-problem.title" = "VPN"; + +/* Placeholder for the Feature Request step in the Privacy Pro feedback form */ +"ppro.feedback-form.request-feature.placeholder" = "What feature would you like to see?"; + +/* Title for the Feature Request step in the Privacy Pro feedback form */ +"ppro.feedback-form.request-feature.title" = "Feature Request"; + +/* Title for the category selection section in the Privacy Pro feedback form */ +"ppro.feedback-form.select-category.title" = "Select a category"; + +/* Text for the body of the PPro feedback form */ +"ppro.feedback-form.text-1" = "Found an issue not covered in our [help center](duck://)? We definitely want to know about it."; + +/* Text for the body of the PPro feedback form */ +"ppro.feedback-form.text-2" = "In addition to the details entered above, we send some anonymized info with your feedback:"; + +/* Bullet text for the body of the PPro feedback form */ +"ppro.feedback-form.text-3" = "• Whether some browser features are active"; + +/* Bullet text for the body of the PPro feedback form */ +"ppro.feedback-form.text-4" = "• Aggregate app diagnostics (e.g., error codes)"; + +/* Text for the body of the PPro feedback form */ +"ppro.feedback-form.text-5" = "By tapping \"Submit\" you agree that DuckDuckGo may use information submitted to improve the app."; + +/* Title for the text box in the Privacy Pro feedback form */ +"ppro.feedback-form.text-box.title" = "Feedback"; + +/* Title for the Privacy Pro feedback form */ +"ppro.feedback-form.title" = "Send Feedback"; + /* Button title for sync bookmarks limits exceeded warning to go to manage bookmarks */ "prefrences.sync.bookmarks-limit-exceeded-action" = "Manage Bookmarks"; @@ -1897,6 +2011,18 @@ https://duckduckgo.com/mac"; /* No comment provided by engineer. */ "section.title.favorites" = "Favorites"; +/* Name of the option the user can chose to give general browser feedback */ +"send.browser.feedback.general-feedback" = "General feedback"; + +/* Name of the option the user can chose to give browser feedback about a problem they enountered */ +"send.browser.feedback.report-problem" = "Report a problem"; + +/* Name of the option the user can chose to give browser feedback about a feature they would like */ +"send.browser.feedback.request-feature" = "Request a feature"; + +/* Title of the picker where the user can chose the category of the feedback they want ot send. */ +"send.browser.feedback.select-category" = "Select a category"; + /* Settings cell for About DDG */ "settings.about.ddg" = "About DuckDuckGo"; @@ -1958,6 +2084,9 @@ But if you *do* want a peek under the hood, you can find more information about /* Section footer Autolock description */ "settings.autolock.description" = "If Touch ID, Face ID, or a system passcode is enabled, you'll be asked to unlock the app when opening it."; +/* Settings cell for Browser Feedback */ +"settings.browser.feedback" = "Browser Feedback"; + /* Settings screen cell text for Automatically Clearing Data */ "settings.clear.data" = "Automatically Clear Data"; @@ -2298,6 +2427,9 @@ But if you *do* want a peek under the hood, you can find more information about /* FAQ Description */ "subscription.faq.description" = "Get answers to frequently asked questions or contact Privacy Pro support from our help pages."; +/* Send Feedback Button */ +"subscription.feedback" = "Send Feedback"; + /* Cancel action for the existing subscription dialog */ "subscription.found.cancel" = "Cancel"; diff --git a/DuckDuckGoTests/NetworkProtectionStatusViewModelTests.swift b/DuckDuckGoTests/NetworkProtectionStatusViewModelTests.swift index fda6f82929..47428ed53d 100644 --- a/DuckDuckGoTests/NetworkProtectionStatusViewModelTests.swift +++ b/DuckDuckGoTests/NetworkProtectionStatusViewModelTests.swift @@ -44,7 +44,8 @@ final class NetworkProtectionStatusViewModelTests: XCTestCase { settings: VPNSettings(defaults: .networkProtectionGroupDefaults), statusObserver: statusObserver, serverInfoObserver: serverInfoObserver, - locationListRepository: MockNetworkProtectionLocationListRepository()) + locationListRepository: MockNetworkProtectionLocationListRepository(), + usesUnifiedFeedbackForm: false) } override func tearDown() {