diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 074739e6fe..df3f38cb87 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1117,9 +1117,7 @@ 4B0511CA262CAA5A00F6079C /* FireproofDomainsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0511B4262CAA5A00F6079C /* FireproofDomainsViewController.swift */; }; 4B0511E1262CAA8600F6079C /* NSOpenPanelExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0511DF262CAA8600F6079C /* NSOpenPanelExtensions.swift */; }; 4B0511E2262CAA8600F6079C /* NSViewControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0511E0262CAA8600F6079C /* NSViewControllerExtension.swift */; }; - 4B05265E2B1AE5C70054955A /* VPNMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B05265D2B1AE5C70054955A /* VPNMetadataCollector.swift */; }; - 4B0526612B1D55320054955A /* VPNFeedbackSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0526602B1D55320054955A /* VPNFeedbackSender.swift */; }; - 4B0526642B1D55D80054955A /* VPNFeedbackCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0526632B1D55D80054955A /* VPNFeedbackCategory.swift */; }; + 4B0526612B1D55320054955A /* UnifiedFeedbackSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0526602B1D55320054955A /* UnifiedFeedbackSender.swift */; }; 4B0A63E8289DB58E00378EF7 /* FirefoxFaviconsReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0A63E7289DB58E00378EF7 /* FirefoxFaviconsReader.swift */; }; 4B0AACAC28BC63ED001038AC /* ChromiumFaviconsReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0AACAB28BC63ED001038AC /* ChromiumFaviconsReader.swift */; }; 4B0AACAE28BC6FD0001038AC /* SafariFaviconsReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0AACAD28BC6FD0001038AC /* SafariFaviconsReader.swift */; }; @@ -1174,12 +1172,10 @@ 4B41EDA72B1543C9001EEDF4 /* PreferencesVPNView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDA62B1543C9001EEDF4 /* PreferencesVPNView.swift */; }; 4B41EDA82B1543C9001EEDF4 /* PreferencesVPNView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDA62B1543C9001EEDF4 /* PreferencesVPNView.swift */; }; 4B41EDAB2B1544B2001EEDF4 /* LoginItems in Frameworks */ = {isa = PBXBuildFile; productRef = 4B41EDAA2B1544B2001EEDF4 /* LoginItems */; }; - 4B41EDAE2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift */; }; - 4B41EDAF2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift */; }; - 4B41EDB12B168B1E001EEDF4 /* VPNFeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB02B168B1E001EEDF4 /* VPNFeedbackFormView.swift */; }; - 4B41EDB22B168B1E001EEDF4 /* VPNFeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB02B168B1E001EEDF4 /* VPNFeedbackFormView.swift */; }; - 4B41EDB42B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */; }; - 4B41EDB52B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */; }; + 4B41EDAE2B168AFF001EEDF4 /* UnifiedFeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* UnifiedFeedbackFormViewController.swift */; }; + 4B41EDAF2B168AFF001EEDF4 /* UnifiedFeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* UnifiedFeedbackFormViewController.swift */; }; + 4B41EDB42B168C55001EEDF4 /* UnifiedFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* UnifiedFeedbackFormViewModel.swift */; }; + 4B41EDB52B168C55001EEDF4 /* UnifiedFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* UnifiedFeedbackFormViewModel.swift */; }; 4B434690285ED7A100177407 /* BookmarksBarViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B43468F285ED7A100177407 /* BookmarksBarViewModelTests.swift */; }; 4B43469528655D1400177407 /* FirefoxDataImporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B43469428655D1400177407 /* FirefoxDataImporterTests.swift */; }; 4B44FEF32B1FEF5A000619D8 /* FocusableTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B44FEF22B1FEF5A000619D8 /* FocusableTextEditor.swift */; }; @@ -1381,8 +1377,8 @@ 4BD18F01283F0BC500058124 /* BookmarksBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD18EFF283F0BC500058124 /* BookmarksBarViewController.swift */; }; 4BD18F05283F151F00058124 /* BookmarksBar.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4BD18F04283F151F00058124 /* BookmarksBar.storyboard */; }; 4BDFA4AE27BF19E500648192 /* ToggleableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */; }; - 4BE344EE2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE344ED2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift */; }; - 4BE344EF2B23786F003FC223 /* VPNFeedbackFormViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE344ED2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift */; }; + 4BE344EE2B2376DF003FC223 /* UnifiedFeedbackFormViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE344ED2B2376DF003FC223 /* UnifiedFeedbackFormViewModelTests.swift */; }; + 4BE344EF2B23786F003FC223 /* UnifiedFeedbackFormViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE344ED2B2376DF003FC223 /* UnifiedFeedbackFormViewModelTests.swift */; }; 4BE3A6C12C16BEB1003FC378 /* VPNRedditSessionWorkaround.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE3A6C02C16BEB1003FC378 /* VPNRedditSessionWorkaround.swift */; }; 4BE3A6C22C16BEB1003FC378 /* VPNRedditSessionWorkaround.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE3A6C02C16BEB1003FC378 /* VPNRedditSessionWorkaround.swift */; }; 4BE4005327CF3DC3007D3161 /* SavePaymentMethodPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE4005227CF3DC3007D3161 /* SavePaymentMethodPopover.swift */; }; @@ -1424,9 +1420,7 @@ 4BF97AD72B43C53D00EB4240 /* NetworkProtectionIPCTunnelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA7CC4D2AD11F6F0042E5CE /* NetworkProtectionIPCTunnelController.swift */; }; 4BF97AD82B43C5B300EB4240 /* NetworkProtectionAppEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2DDCF72A93A8BB0039D884 /* NetworkProtectionAppEvents.swift */; }; 4BF97AD92B43C5C000EB4240 /* Bundle+VPN.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4D605E2A0B29FA00BCD287 /* Bundle+VPN.swift */; }; - 4BF97ADA2B43C5DC00EB4240 /* VPNFeedbackCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0526632B1D55D80054955A /* VPNFeedbackCategory.swift */; }; - 4BF97ADB2B43C5E000EB4240 /* VPNMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B05265D2B1AE5C70054955A /* VPNMetadataCollector.swift */; }; - 4BF97ADC2B43C5E200EB4240 /* VPNFeedbackSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0526602B1D55320054955A /* VPNFeedbackSender.swift */; }; + 4BF97ADC2B43C5E200EB4240 /* UnifiedFeedbackSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0526602B1D55320054955A /* UnifiedFeedbackSender.swift */; }; 4BF97ADD2B43C5FC00EB4240 /* KeychainType+ClientDefault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD3AF5C2A8E7AF1006F9F56 /* KeychainType+ClientDefault.swift */; }; 5601FECD29B7973D00068905 /* TabBarViewItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5601FECC29B7973D00068905 /* TabBarViewItemTests.swift */; }; 5603D90629B7B746007F9F01 /* MockTabViewItemDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5603D90529B7B746007F9F01 /* MockTabViewItemDelegate.swift */; }; @@ -2555,6 +2549,14 @@ BD384ACA2BBC821A00EF3735 /* vpn-light-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = BD384AC72BBC821100EF3735 /* vpn-light-mode.json */; }; BD384ACB2BBC821B00EF3735 /* vpn-dark-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = BD384AC82BBC821100EF3735 /* vpn-dark-mode.json */; }; BD384ACC2BBC821B00EF3735 /* vpn-light-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = BD384AC72BBC821100EF3735 /* vpn-light-mode.json */; }; + BD7090CF2C5182FB009EED82 /* UnifiedFeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7090CE2C5182FB009EED82 /* UnifiedFeedbackFormView.swift */; }; + BD7090D02C5182FB009EED82 /* UnifiedFeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7090CE2C5182FB009EED82 /* UnifiedFeedbackFormView.swift */; }; + BD7090D22C52ECFE009EED82 /* UnifiedMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7090D12C52ECFE009EED82 /* UnifiedMetadataCollector.swift */; }; + BD7090D32C52ECFE009EED82 /* UnifiedMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7090D12C52ECFE009EED82 /* UnifiedMetadataCollector.swift */; }; + BD7090D62C540D5D009EED82 /* EmptyMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7090D52C540D5D009EED82 /* EmptyMetadataCollector.swift */; }; + BD7090D72C540D5D009EED82 /* EmptyMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7090D52C540D5D009EED82 /* EmptyMetadataCollector.swift */; }; + BD88A83E2C4F3E4300460A26 /* FeedbackCategoryProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD88A83D2C4F3E4300460A26 /* FeedbackCategoryProviding.swift */; }; + BD88A83F2C4F3E4300460A26 /* FeedbackCategoryProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD88A83D2C4F3E4300460A26 /* FeedbackCategoryProviding.swift */; }; BDA7647C2BC497BE00D0400C /* DefaultVPNLocationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA7647B2BC497BE00D0400C /* DefaultVPNLocationFormatter.swift */; }; BDA7647D2BC497BE00D0400C /* DefaultVPNLocationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA7647B2BC497BE00D0400C /* DefaultVPNLocationFormatter.swift */; }; BDA7647F2BC4998900D0400C /* DefaultVPNLocationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA7647B2BC497BE00D0400C /* DefaultVPNLocationFormatter.swift */; }; @@ -2569,6 +2571,20 @@ BDADBDCB2BD2BC2800421B9B /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = BDADBDCA2BD2BC2800421B9B /* Lottie */; }; BDADBDCC2BD2BC4D00421B9B /* vpn-dark-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = BD384AC82BBC821100EF3735 /* vpn-dark-mode.json */; }; BDADBDCD2BD2BC5700421B9B /* vpn-light-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = BD384AC72BBC821100EF3735 /* vpn-light-mode.json */; }; + BDBA85902C5D252A00BC54F5 /* VPNFeedbackSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBA858F2C5D252A00BC54F5 /* VPNFeedbackSender.swift */; }; + BDBA85912C5D252A00BC54F5 /* VPNFeedbackSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBA858F2C5D252A00BC54F5 /* VPNFeedbackSender.swift */; }; + BDBA85932C5D255200BC54F5 /* VPNFeedbackCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBA85922C5D255200BC54F5 /* VPNFeedbackCategory.swift */; }; + BDBA85942C5D255200BC54F5 /* VPNFeedbackCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBA85922C5D255200BC54F5 /* VPNFeedbackCategory.swift */; }; + BDBA85962C5D256C00BC54F5 /* VPNFeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBA85952C5D256C00BC54F5 /* VPNFeedbackFormView.swift */; }; + BDBA85972C5D256C00BC54F5 /* VPNFeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBA85952C5D256C00BC54F5 /* VPNFeedbackFormView.swift */; }; + BDBA85992C5D258100BC54F5 /* VPNFeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBA85982C5D258100BC54F5 /* VPNFeedbackFormViewController.swift */; }; + BDBA859A2C5D258100BC54F5 /* VPNFeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBA85982C5D258100BC54F5 /* VPNFeedbackFormViewController.swift */; }; + BDBA859C2C5D25A300BC54F5 /* VPNFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBA859B2C5D25A300BC54F5 /* VPNFeedbackFormViewModel.swift */; }; + BDBA859D2C5D25A300BC54F5 /* VPNFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBA859B2C5D25A300BC54F5 /* VPNFeedbackFormViewModel.swift */; }; + BDBA859F2C5D25B700BC54F5 /* VPNMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBA859E2C5D25B700BC54F5 /* VPNMetadataCollector.swift */; }; + BDBA85A02C5D25B700BC54F5 /* VPNMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBA859E2C5D25B700BC54F5 /* VPNMetadataCollector.swift */; }; + BDCB66D82C7CE1A600E8ABC9 /* VPNFeedbackFormViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDCB66D72C7CE1A600E8ABC9 /* VPNFeedbackFormViewModelTests.swift */; }; + BDCB66D92C7CE1A700E8ABC9 /* VPNFeedbackFormViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDCB66D72C7CE1A600E8ABC9 /* VPNFeedbackFormViewModelTests.swift */; }; BDE981D92BBD10D600645880 /* vpn-dark-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = BD384AC82BBC821100EF3735 /* vpn-dark-mode.json */; }; BDE981DA2BBD10D600645880 /* vpn-light-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = BD384AC72BBC821100EF3735 /* vpn-light-mode.json */; }; C1372EF42BBC5BAD003F8793 /* SecureTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1372EF32BBC5BAD003F8793 /* SecureTextField.swift */; }; @@ -3229,9 +3245,7 @@ 4B0511B4262CAA5A00F6079C /* FireproofDomainsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FireproofDomainsViewController.swift; sourceTree = ""; }; 4B0511DF262CAA8600F6079C /* NSOpenPanelExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSOpenPanelExtensions.swift; sourceTree = ""; }; 4B0511E0262CAA8600F6079C /* NSViewControllerExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSViewControllerExtension.swift; sourceTree = ""; }; - 4B05265D2B1AE5C70054955A /* VPNMetadataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNMetadataCollector.swift; sourceTree = ""; }; - 4B0526602B1D55320054955A /* VPNFeedbackSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackSender.swift; sourceTree = ""; }; - 4B0526632B1D55D80054955A /* VPNFeedbackCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackCategory.swift; sourceTree = ""; }; + 4B0526602B1D55320054955A /* UnifiedFeedbackSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedFeedbackSender.swift; sourceTree = ""; }; 4B0A63E7289DB58E00378EF7 /* FirefoxFaviconsReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirefoxFaviconsReader.swift; sourceTree = ""; }; 4B0AACAB28BC63ED001038AC /* ChromiumFaviconsReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromiumFaviconsReader.swift; sourceTree = ""; }; 4B0AACAD28BC6FD0001038AC /* SafariFaviconsReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariFaviconsReader.swift; sourceTree = ""; }; @@ -3271,9 +3285,8 @@ 4B41ED9F2B15437A001EEDF4 /* NetworkProtectionNotificationsPresenterFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkProtectionNotificationsPresenterFactory.swift; sourceTree = ""; }; 4B41EDA22B1543B9001EEDF4 /* VPNPreferencesModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VPNPreferencesModel.swift; sourceTree = ""; }; 4B41EDA62B1543C9001EEDF4 /* PreferencesVPNView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesVPNView.swift; sourceTree = ""; }; - 4B41EDAD2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewController.swift; sourceTree = ""; }; - 4B41EDB02B168B1E001EEDF4 /* VPNFeedbackFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormView.swift; sourceTree = ""; }; - 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewModel.swift; sourceTree = ""; }; + 4B41EDAD2B168AFF001EEDF4 /* UnifiedFeedbackFormViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedFeedbackFormViewController.swift; sourceTree = ""; }; + 4B41EDB32B168C55001EEDF4 /* UnifiedFeedbackFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedFeedbackFormViewModel.swift; sourceTree = ""; }; 4B43468F285ED7A100177407 /* BookmarksBarViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksBarViewModelTests.swift; sourceTree = ""; }; 4B43469428655D1400177407 /* FirefoxDataImporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirefoxDataImporterTests.swift; sourceTree = ""; }; 4B44FEF22B1FEF5A000619D8 /* FocusableTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextEditor.swift; sourceTree = ""; }; @@ -3441,7 +3454,7 @@ 4BD18EFF283F0BC500058124 /* BookmarksBarViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarksBarViewController.swift; sourceTree = ""; }; 4BD18F04283F151F00058124 /* BookmarksBar.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = BookmarksBar.storyboard; sourceTree = ""; }; 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleableScrollView.swift; sourceTree = ""; }; - 4BE344ED2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewModelTests.swift; sourceTree = ""; }; + 4BE344ED2B2376DF003FC223 /* UnifiedFeedbackFormViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedFeedbackFormViewModelTests.swift; sourceTree = ""; }; 4BE3A6C02C16BEB1003FC378 /* VPNRedditSessionWorkaround.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNRedditSessionWorkaround.swift; sourceTree = ""; }; 4BE4005227CF3DC3007D3161 /* SavePaymentMethodPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavePaymentMethodPopover.swift; sourceTree = ""; }; 4BE4005427CF3F19007D3161 /* SavePaymentMethodViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavePaymentMethodViewController.swift; sourceTree = ""; }; @@ -4244,9 +4257,20 @@ BBFF355C2C4AF26200DA3289 /* BookmarksSortModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksSortModeTests.swift; sourceTree = ""; }; BD384AC72BBC821100EF3735 /* vpn-light-mode.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "vpn-light-mode.json"; sourceTree = ""; }; BD384AC82BBC821100EF3735 /* vpn-dark-mode.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "vpn-dark-mode.json"; sourceTree = ""; }; + BD7090CE2C5182FB009EED82 /* UnifiedFeedbackFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedFeedbackFormView.swift; sourceTree = ""; }; + BD7090D12C52ECFE009EED82 /* UnifiedMetadataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedMetadataCollector.swift; sourceTree = ""; }; + BD7090D52C540D5D009EED82 /* EmptyMetadataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyMetadataCollector.swift; sourceTree = ""; }; + BD88A83D2C4F3E4300460A26 /* FeedbackCategoryProviding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackCategoryProviding.swift; sourceTree = ""; }; BDA7647B2BC497BE00D0400C /* DefaultVPNLocationFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultVPNLocationFormatter.swift; sourceTree = ""; }; BDA7648C2BC4E4EF00D0400C /* DefaultVPNLocationFormatterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultVPNLocationFormatterTests.swift; sourceTree = ""; }; BDA764902BC4E57200D0400C /* MockVPNLocationFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockVPNLocationFormatter.swift; sourceTree = ""; }; + BDBA858F2C5D252A00BC54F5 /* VPNFeedbackSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackSender.swift; sourceTree = ""; }; + BDBA85922C5D255200BC54F5 /* VPNFeedbackCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackCategory.swift; sourceTree = ""; }; + BDBA85952C5D256C00BC54F5 /* VPNFeedbackFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormView.swift; sourceTree = ""; }; + BDBA85982C5D258100BC54F5 /* VPNFeedbackFormViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewController.swift; sourceTree = ""; }; + BDBA859B2C5D25A300BC54F5 /* VPNFeedbackFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewModel.swift; sourceTree = ""; }; + BDBA859E2C5D25B700BC54F5 /* VPNMetadataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNMetadataCollector.swift; sourceTree = ""; }; + BDCB66D72C7CE1A600E8ABC9 /* VPNFeedbackFormViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewModelTests.swift; sourceTree = ""; }; C1372EF32BBC5BAD003F8793 /* SecureTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureTextField.swift; sourceTree = ""; }; C13909EE2B85FD4E001626ED /* AutofillActionExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillActionExecutor.swift; sourceTree = ""; }; C13909F32B85FD79001626ED /* AutofillDeleteAllPasswordsExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillDeleteAllPasswordsExecutorTests.swift; sourceTree = ""; }; @@ -5325,17 +5349,17 @@ path = Surveys; sourceTree = ""; }; - 4B41EDAC2B168A66001EEDF4 /* VPNFeedbackForm */ = { + 4B41EDAC2B168A66001EEDF4 /* UnifiedFeedbackForm */ = { isa = PBXGroup; children = ( - 4B41EDAD2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift */, - 4B41EDB02B168B1E001EEDF4 /* VPNFeedbackFormView.swift */, - 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */, - 4B0526632B1D55D80054955A /* VPNFeedbackCategory.swift */, - 4B05265D2B1AE5C70054955A /* VPNMetadataCollector.swift */, - 4B0526602B1D55320054955A /* VPNFeedbackSender.swift */, + BD88A83D2C4F3E4300460A26 /* FeedbackCategoryProviding.swift */, + 4B41EDAD2B168AFF001EEDF4 /* UnifiedFeedbackFormViewController.swift */, + 4B41EDB32B168C55001EEDF4 /* UnifiedFeedbackFormViewModel.swift */, + BD7090D42C540C0D009EED82 /* MetadataCollectors */, + 4B0526602B1D55320054955A /* UnifiedFeedbackSender.swift */, + BD7090CE2C5182FB009EED82 /* UnifiedFeedbackFormView.swift */, ); - path = VPNFeedbackForm; + path = UnifiedFeedbackForm; sourceTree = ""; }; 4B43468D285ED6BD00177407 /* BookmarksBar */ = { @@ -5985,6 +6009,7 @@ 7B09CBA72BA4BE7000CF245B /* NetworkProtectionPixelEventTests.swift */, BDA7648C2BC4E4EF00D0400C /* DefaultVPNLocationFormatterTests.swift */, 7B4C5CF42BE51D640007A164 /* VPNUninstallerTests.swift */, + BDCB66D72C7CE1A600E8ABC9 /* VPNFeedbackFormViewModelTests.swift */, ); path = NetworkProtection; sourceTree = ""; @@ -6004,12 +6029,12 @@ path = View; sourceTree = ""; }; - 4BE344EC2B2376AE003FC223 /* VPNFeedbackForm */ = { + 4BE344EC2B2376AE003FC223 /* UnifiedFeedbackForm */ = { isa = PBXGroup; children = ( - 4BE344ED2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift */, + 4BE344ED2B2376DF003FC223 /* UnifiedFeedbackFormViewModelTests.swift */, ); - path = VPNFeedbackForm; + path = UnifiedFeedbackForm; sourceTree = ""; }; 4BF6961B28BE90E800D402D4 /* HomePage */ = { @@ -6960,7 +6985,8 @@ B6040859274B8C5200680351 /* UnprotectedDomains */, 1D72D5902BFF361700AEDE36 /* Updates */, AACF6FD426BC35C200CF09F9 /* UserAgent */, - 4B41EDAC2B168A66001EEDF4 /* VPNFeedbackForm */, + BDBA858E2C5D24EF00BC54F5 /* VPNFeedbackForm */, + 4B41EDAC2B168A66001EEDF4 /* UnifiedFeedbackForm */, 4B9DB0062A983B23000927DB /* Waitlist */, AA6EF9AE25066F99004754E6 /* Windows */, 31F28C4B28C8EE9000119F70 /* YoutubePlayer */, @@ -7022,7 +7048,7 @@ 4B9DB04D2A983B55000927DB /* Waitlist */, 3776582B27F7163B009A6B35 /* WebsiteBreakageReport */, 376718FE28E58504003A2A15 /* YoutubePlayer */, - 4BE344EC2B2376AE003FC223 /* VPNFeedbackForm */, + 4BE344EC2B2376AE003FC223 /* UnifiedFeedbackForm */, AA585D96248FD31400E9A3E2 /* Info.plist */, ); path = UnitTests; @@ -8437,6 +8463,15 @@ path = View; sourceTree = ""; }; + BD7090D42C540C0D009EED82 /* MetadataCollectors */ = { + isa = PBXGroup; + children = ( + BD7090D12C52ECFE009EED82 /* UnifiedMetadataCollector.swift */, + BD7090D52C540D5D009EED82 /* EmptyMetadataCollector.swift */, + ); + path = MetadataCollectors; + sourceTree = ""; + }; BDA7648F2BC4E56200D0400C /* Mocks */ = { isa = PBXGroup; children = ( @@ -8445,6 +8480,19 @@ path = Mocks; sourceTree = ""; }; + BDBA858E2C5D24EF00BC54F5 /* VPNFeedbackForm */ = { + isa = PBXGroup; + children = ( + BDBA858F2C5D252A00BC54F5 /* VPNFeedbackSender.swift */, + BDBA85922C5D255200BC54F5 /* VPNFeedbackCategory.swift */, + BDBA85952C5D256C00BC54F5 /* VPNFeedbackFormView.swift */, + BDBA85982C5D258100BC54F5 /* VPNFeedbackFormViewController.swift */, + BDBA859B2C5D25A300BC54F5 /* VPNFeedbackFormViewModel.swift */, + BDBA859E2C5D25B700BC54F5 /* VPNMetadataCollector.swift */, + ); + path = VPNFeedbackForm; + sourceTree = ""; + }; BDE981DB2BBD110800645880 /* Assets */ = { isa = PBXGroup; children = ( @@ -9956,6 +10004,7 @@ 7BFF35742C10D75000F89673 /* IPCServiceLauncher.swift in Sources */, F1D042A22BFBB4DD00A31506 /* DataBrokerProtectionSettings+Environment.swift in Sources */, 3706FAA3293F65D500E42796 /* CrashReportPromptViewController.swift in Sources */, + BD88A83F2C4F3E4300460A26 /* FeedbackCategoryProviding.swift in Sources */, 3706FAA4293F65D500E42796 /* ContextMenuManager.swift in Sources */, 31AA6B982B960BA50025014E /* DataBrokerProtectionLoginItemPixels.swift in Sources */, 3706FAA5293F65D500E42796 /* GradientView.swift in Sources */, @@ -10096,7 +10145,6 @@ 3706FB05293F65D500E42796 /* Favicons.xcdatamodeld in Sources */, 3706FB07293F65D500E42796 /* Publisher.asVoid.swift in Sources */, 3706FB08293F65D500E42796 /* NavigationButtonMenuDelegate.swift in Sources */, - 4BF97ADB2B43C5E000EB4240 /* VPNMetadataCollector.swift in Sources */, 1DDC84F82B83558F00670238 /* PreferencesPrivateSearchView.swift in Sources */, 3706FB09293F65D500E42796 /* CrashReport.swift in Sources */, 1E0C72072ABC63BD00802009 /* SubscriptionPagesUserScript.swift in Sources */, @@ -10160,6 +10208,7 @@ 3706FB35293F65D500E42796 /* FlatButton.swift in Sources */, 3706FB36293F65D500E42796 /* PinnedTabView.swift in Sources */, 3706FB37293F65D500E42796 /* DataEncryption.swift in Sources */, + BD7090D32C52ECFE009EED82 /* UnifiedMetadataCollector.swift in Sources */, 56BA1E762BAAF70F001CF69F /* SSLErrorPageTabExtension.swift in Sources */, 4B9DB0362A983B24000927DB /* WaitlistTermsAndConditionsView.swift in Sources */, 37197EA82942443D00394917 /* BrowserTabViewController.swift in Sources */, @@ -10213,6 +10262,7 @@ 3706FB5D293F65D500E42796 /* VisitMenuItem.swift in Sources */, 3706FB5E293F65D500E42796 /* EncryptionKeyStore.swift in Sources */, F1DA51872BF607D200CF29FA /* SubscriptionAttributionPixelHandler.swift in Sources */, + BDBA85A02C5D25B700BC54F5 /* VPNMetadataCollector.swift in Sources */, 3706FB60293F65D500E42796 /* PasswordManagementIdentityItemView.swift in Sources */, 3706FB61293F65D500E42796 /* ProgressExtension.swift in Sources */, 3706FB62293F65D500E42796 /* CSVParser.swift in Sources */, @@ -10271,6 +10321,7 @@ 3706FB82293F65D500E42796 /* PasswordManagementNoteItemView.swift in Sources */, 4BF97AD82B43C5B300EB4240 /* NetworkProtectionAppEvents.swift in Sources */, 3706FEC5293F6F0600E42796 /* BWInstallationService.swift in Sources */, + BDBA85972C5D256C00BC54F5 /* VPNFeedbackFormView.swift in Sources */, 3775912E29AAC72700E26367 /* SyncPreferences.swift in Sources */, 3706FB83293F65D500E42796 /* NSApplicationExtension.swift in Sources */, 37197EA42942441D00394917 /* NewWindowPolicy.swift in Sources */, @@ -10310,6 +10361,7 @@ B690152D2ACBF4DA00AD0BAB /* MenuPreview.swift in Sources */, 1D36F4252A3B85C50052B527 /* TabCleanupPreparer.swift in Sources */, 3706FB97293F65D500E42796 /* ActionSpeech.swift in Sources */, + BD7090D72C540D5D009EED82 /* EmptyMetadataCollector.swift in Sources */, B6AFE6BD29A5D621002FF962 /* HTTPSUpgradeTabExtension.swift in Sources */, 3706FB9A293F65D500E42796 /* FireproofDomainsStore.swift in Sources */, 3706FB9B293F65D500E42796 /* PrivacyDashboardPermissionHandler.swift in Sources */, @@ -10335,7 +10387,7 @@ 3706FBA4293F65D500E42796 /* ContentOverlayPopover.swift in Sources */, 3706FBA5293F65D500E42796 /* TabShadowView.swift in Sources */, 3706FBA7293F65D500E42796 /* EncryptedValueTransformer.swift in Sources */, - 4B41EDAF2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */, + 4B41EDAF2B168AFF001EEDF4 /* UnifiedFeedbackFormViewController.swift in Sources */, 31EF1E802B63FFA800E6DB17 /* DBPHomeViewController.swift in Sources */, 3706FBA8293F65D500E42796 /* PasteboardBookmark.swift in Sources */, 3706FBA9293F65D500E42796 /* PinnedTabsManager.swift in Sources */, @@ -10345,6 +10397,8 @@ 9FBD84532BB3AACB00220859 /* AttributionOriginFileProvider.swift in Sources */, 4BF97AD92B43C5C000EB4240 /* Bundle+VPN.swift in Sources */, 3706FBAE293F65D500E42796 /* DataImport.swift in Sources */, + BDBA859A2C5D258100BC54F5 /* VPNFeedbackFormViewController.swift in Sources */, + BDBA859D2C5D25A300BC54F5 /* VPNFeedbackFormViewModel.swift in Sources */, 3706FBAF293F65D500E42796 /* FireproofDomains.xcdatamodeld in Sources */, B626A7552991413000053070 /* SerpHeadersNavigationResponder.swift in Sources */, B68D21C42ACBC917002DA3C2 /* ContentBlockingMock.swift in Sources */, @@ -10380,7 +10434,7 @@ 3706FBC8293F65D500E42796 /* FirePopoverCollectionViewItem.swift in Sources */, 3706FBC9293F65D500E42796 /* ArrayExtension.swift in Sources */, 3706FBCB293F65D500E42796 /* BookmarkHTMLImporter.swift in Sources */, - 4BF97ADC2B43C5E200EB4240 /* VPNFeedbackSender.swift in Sources */, + 4BF97ADC2B43C5E200EB4240 /* UnifiedFeedbackSender.swift in Sources */, 987799F72999996B005D8EB6 /* BookmarkDatabase.swift in Sources */, F1C5763F2BFF972900C78647 /* SubscriptionUIHandling.swift in Sources */, BBE013EB2C5BFD660025F2C6 /* BookmarksEmptyStateContent.swift in Sources */, @@ -10411,7 +10465,7 @@ 3706FBDB293F65D500E42796 /* DefaultBrowserPreferences.swift in Sources */, 9FEE98662B846870002E44E8 /* AddEditBookmarkView.swift in Sources */, 3706FBDC293F65D500E42796 /* Permissions.xcdatamodeld in Sources */, - 4B41EDB52B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */, + 4B41EDB52B168C55001EEDF4 /* UnifiedFeedbackFormViewModel.swift in Sources */, 371209302C233D66003ADF3D /* RemoteMessagingDatabase.swift in Sources */, 3706FBDD293F65D500E42796 /* PaddedImageButton.swift in Sources */, 37CEFCAA2A6737A2001EF741 /* CredentialsCleanupErrorHandling.swift in Sources */, @@ -10437,6 +10491,7 @@ 3706FBE8293F65D500E42796 /* SuggestionTableCellView.swift in Sources */, 3706FBE9293F65D500E42796 /* FireViewModel.swift in Sources */, B68D21D02ACBC9FD002DA3C2 /* ContentBlockerRulesManagerMock.swift in Sources */, + BD7090D02C5182FB009EED82 /* UnifiedFeedbackFormView.swift in Sources */, 3706FEC6293F6F0600E42796 /* BWKeyStorage.swift in Sources */, 4B6785482AA8DE69008A5004 /* VPNUninstaller.swift in Sources */, 3706FBEC293F65D500E42796 /* EditableTextView.swift in Sources */, @@ -10460,6 +10515,7 @@ 379E877729E98729001C8BB0 /* BookmarksCleanupErrorHandling.swift in Sources */, 3706FBFB293F65D500E42796 /* MoreOrLessView.swift in Sources */, 987799FA29999973005D8EB6 /* LocalBookmarkStore.swift in Sources */, + BDBA85942C5D255200BC54F5 /* VPNFeedbackCategory.swift in Sources */, B602E7D02A93A5FF00F12201 /* WKBackForwardListExtension.swift in Sources */, 3706FBFE293F65D500E42796 /* History.xcdatamodeld in Sources */, B68D21C92ACBC96E002DA3C2 /* MockPrivacyConfiguration.swift in Sources */, @@ -10586,6 +10642,7 @@ 3706FC4A293F65D500E42796 /* LocalStatisticsStore.swift in Sources */, 3706FC4B293F65D500E42796 /* BackForwardListItem.swift in Sources */, 4B4D60DD2A0C875E00BCD287 /* NetworkProtectionOptionKeyExtension.swift in Sources */, + BDBA85912C5D252A00BC54F5 /* VPNFeedbackSender.swift in Sources */, 31267C6A2B640C4B00FEF811 /* DataBrokerProtectionFeatureDisabler.swift in Sources */, 3707C723294B5D2900682A9F /* URLSessionExtension.swift in Sources */, 3706FC4E293F65D500E42796 /* AtbAndVariantCleanup.swift in Sources */, @@ -10641,7 +10698,6 @@ 3706FC6A293F65D500E42796 /* NSWorkspaceExtension.swift in Sources */, B6C0BB6829AEFF8100AE8E3C /* BookmarkExtension.swift in Sources */, B69A14FB2B4D705D00B9417D /* BookmarkFolderPicker.swift in Sources */, - 4B41EDB22B168B1E001EEDF4 /* VPNFeedbackFormView.swift in Sources */, 3706FC6C293F65D500E42796 /* BookmarkViewModel.swift in Sources */, 3706FC6D293F65D500E42796 /* DaxSpeech.swift in Sources */, 3706FC6E293F65D500E42796 /* DuckURLSchemeHandler.swift in Sources */, @@ -10673,7 +10729,6 @@ 371209312C233D69003ADF3D /* RemoteMessagingStoreErrorHandling.swift in Sources */, 3706FC83293F65D500E42796 /* PopoverMessageViewController.swift in Sources */, 7B5A23702C46A116007213AC /* ExcludedDomainsModel.swift in Sources */, - 4BF97ADA2B43C5DC00EB4240 /* VPNFeedbackCategory.swift in Sources */, 9D9AE86E2AA76D1F0026E7DC /* LoginItem+NetworkProtection.swift in Sources */, 3707C721294B5D2900682A9F /* WKMenuItemIdentifier.swift in Sources */, 3706FEBE293F6EFF00E42796 /* BWMessageIdGenerator.swift in Sources */, @@ -10764,6 +10819,7 @@ 1D1C36E729FB019C001FA40C /* HistoryTabExtensionTests.swift in Sources */, 1D9FDEBB2B9B5E090040B78C /* WebTrackingProtectionPreferencesTests.swift in Sources */, 3706FDF3293F661700E42796 /* ChromiumLoginReaderTests.swift in Sources */, + BDCB66D92C7CE1A700E8ABC9 /* VPNFeedbackFormViewModelTests.swift in Sources */, 3706FDF4293F661700E42796 /* TabCollectionTests.swift in Sources */, 3706FDF5293F661700E42796 /* StartupPreferencesTests.swift in Sources */, 3706FDF6293F661700E42796 /* DuckPlayerTests.swift in Sources */, @@ -10813,7 +10869,7 @@ 1D9FDEC12B9B5FEA0040B78C /* AccessibilityPreferencesTests.swift in Sources */, 3706FE11293F661700E42796 /* URLSuggestedFilenameTests.swift in Sources */, 5681ED472BDBAF6E00F59729 /* SyncErrorHandlerTests.swift in Sources */, - 4BE344EF2B23786F003FC223 /* VPNFeedbackFormViewModelTests.swift in Sources */, + 4BE344EF2B23786F003FC223 /* UnifiedFeedbackFormViewModelTests.swift in Sources */, B6F56569299A414300A04298 /* WKWebViewMockingExtension.swift in Sources */, 3706FE13293F661700E42796 /* ConfigurationStorageTests.swift in Sources */, 3706FE15293F661700E42796 /* PrivacyIconViewModelTests.swift in Sources */, @@ -11407,7 +11463,7 @@ B693955126F04BEB0015B914 /* GradientView.swift in Sources */, 37AFCE8527DA2D3900471A10 /* PreferencesSidebar.swift in Sources */, B6C00ED5292FB21E009C73A6 /* HoveredLinkTabExtension.swift in Sources */, - 4B0526612B1D55320054955A /* VPNFeedbackSender.swift in Sources */, + 4B0526612B1D55320054955A /* UnifiedFeedbackSender.swift in Sources */, AA5C8F5E2590EEE800748EB7 /* NSPointExtension.swift in Sources */, 4BF0E5122AD25A2600FFEC9E /* DuckDuckGoUserAgent.swift in Sources */, AA6EF9AD25066F42004754E6 /* WindowsManager.swift in Sources */, @@ -11435,7 +11491,7 @@ 4BB88B5025B7BA2B006F6B06 /* TabInstrumentation.swift in Sources */, 85D33F1225C82EB3002B91A6 /* ConfigurationManager.swift in Sources */, 31F28C4F28C8EEC500119F70 /* YoutubePlayerUserScript.swift in Sources */, - 4B41EDAE2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */, + 4B41EDAE2B168AFF001EEDF4 /* UnifiedFeedbackFormViewController.swift in Sources */, 7BFF35732C10D75000F89673 /* IPCServiceLauncher.swift in Sources */, AA5FA697275F90C400DCE9C9 /* FaviconImageCache.swift in Sources */, 1430DFF524D0580F00B8978C /* TabBarViewController.swift in Sources */, @@ -11477,6 +11533,7 @@ 7BEC20452B0F505F00243D3E /* AddBookmarkFolderPopoverView.swift in Sources */, B6C0BB6A29AF1C7000AE8E3C /* BrowserTabView.swift in Sources */, B6B1E88026D5DA9B0062C350 /* DownloadsViewController.swift in Sources */, + BD7090D62C540D5D009EED82 /* EmptyMetadataCollector.swift in Sources */, 85AC3AF725D5DBFD00C7D2AA /* DataExtension.swift in Sources */, 85480FCF25D1AA22009424E3 /* ConfigurationStore.swift in Sources */, AA3D531B27A2F57E00074EC1 /* Feedback.swift in Sources */, @@ -11494,6 +11551,7 @@ EEC4A6712B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift in Sources */, 85589E8727BBB8F20038AD11 /* HomePageFavoritesModel.swift in Sources */, 4BB88B4A25B7B690006F6B06 /* SequenceExtensions.swift in Sources */, + BDBA85932C5D255200BC54F5 /* VPNFeedbackCategory.swift in Sources */, B602E7CF2A93A5FF00F12201 /* WKBackForwardListExtension.swift in Sources */, 4B59024026B35F3600489384 /* ChromiumDataImporter.swift in Sources */, B62B48562ADE730D000DECE5 /* FileImportView.swift in Sources */, @@ -11619,6 +11677,7 @@ 4BB99CFE26FE191E001E4761 /* FirefoxBookmarksReader.swift in Sources */, F1DA518C2BF607D200CF29FA /* SubscriptionRedirectManager.swift in Sources */, 4BBC16A227C485BC00E00A38 /* DeviceIdleStateDetector.swift in Sources */, + BDBA859C2C5D25A300BC54F5 /* VPNFeedbackFormViewModel.swift in Sources */, 4B379C2427BDE1B0008A968E /* FlatButton.swift in Sources */, 37054FC92873301700033B6F /* PinnedTabView.swift in Sources */, 4BA1A6A0258B079600F6F690 /* DataEncryption.swift in Sources */, @@ -11695,8 +11754,8 @@ 4B9DB0352A983B24000927DB /* WaitlistTermsAndConditionsView.swift in Sources */, AAB8203C26B2DE0D00788AC3 /* SuggestionListCharacteristics.swift in Sources */, 4B6785472AA8DE68008A5004 /* VPNUninstaller.swift in Sources */, - 4B0526642B1D55D80054955A /* VPNFeedbackCategory.swift in Sources */, 4B9292D42667123700AD2C21 /* BookmarkListViewController.swift in Sources */, + BD88A83E2C4F3E4300460A26 /* FeedbackCategoryProviding.swift in Sources */, 9F56CFB12B843F6C00BB7F11 /* BookmarksDialogViewFactory.swift in Sources */, F1FD5B672C2B0AAA0040FA0D /* SubscriptionPagesUseSubscriptionFeature.swift in Sources */, 4B723E0D26B0006100E14D75 /* SecureVaultLoginImporter.swift in Sources */, @@ -11704,6 +11763,7 @@ 9D9AE86B2AA76CF90026E7DC /* LoginItemsManager.swift in Sources */, 857E5AF52A79045800FC0FB4 /* PixelExperiment.swift in Sources */, B6C416A7294A4AE500C4F2E7 /* DuckPlayerTabExtension.swift in Sources */, + BDBA859F2C5D25B700BC54F5 /* VPNMetadataCollector.swift in Sources */, AA5C1DD5285C780C0089850C /* RecentlyClosedCoordinator.swift in Sources */, AA88D14B252A557100980B4E /* URLRequestExtension.swift in Sources */, AA6197C6276B3168008396F0 /* FaviconHostReference.swift in Sources */, @@ -11812,6 +11872,8 @@ 9F872D982B8DA9F800138637 /* Bookmarks+Tab.swift in Sources */, 85D885B326A5A9DE0077C374 /* NSAlert+PasswordManager.swift in Sources */, 983DFB2528B67036006B7E34 /* UserContentUpdating.swift in Sources */, + BD7090D22C52ECFE009EED82 /* UnifiedMetadataCollector.swift in Sources */, + BD7090CF2C5182FB009EED82 /* UnifiedFeedbackFormView.swift in Sources */, 1D9A4E5A2B43213B00F449E2 /* TabSnapshotExtension.swift in Sources */, 316913262BD2B76F0051B46D /* DataBrokerPrerequisitesStatusVerifier.swift in Sources */, 4B7A57CF279A4EF300B1C70E /* ChromiumPreferences.swift in Sources */, @@ -11828,7 +11890,6 @@ F188267C2BBEB3AA00D9AC4F /* GeneralPixel.swift in Sources */, 4B723E1026B0006700E14D75 /* CSVImporter.swift in Sources */, 37A4CEBA282E992F00D75B89 /* StartupPreferences.swift in Sources */, - 4B41EDB12B168B1E001EEDF4 /* VPNFeedbackFormView.swift in Sources */, 1D72D59C2BFF61B200AEDE36 /* UpdateNotificationPresenter.swift in Sources */, AA4BBA3B25C58FA200C4FB0F /* MainMenu.swift in Sources */, AA585D84248FD31100E9A3E2 /* BrowserTabViewController.swift in Sources */, @@ -11905,7 +11966,6 @@ 9F9C49FD2BC7E9830099738D /* BookmarkAllTabsDialogViewModel.swift in Sources */, 4B9292A226670D2A00AD2C21 /* PseudoFolder.swift in Sources */, 1DDD3EC42B84F96B004CBF2B /* CookiePopupProtectionPreferences.swift in Sources */, - 4B05265E2B1AE5C70054955A /* VPNMetadataCollector.swift in Sources */, 4B9DB0292A983B24000927DB /* WaitlistStorage.swift in Sources */, AA2CB1352587C29500AA6FBE /* TabBarFooter.swift in Sources */, EEC111E6294D06290086524F /* JSAlertViewModel.swift in Sources */, @@ -11941,7 +12001,7 @@ 85C48CCC278D808F00D3263E /* NSAttributedStringExtension.swift in Sources */, 7B5A236F2C46A116007213AC /* ExcludedDomainsModel.swift in Sources */, 1D710F4B2C48F1F200C3975F /* UpdateDialogHelper.swift in Sources */, - 4B41EDB42B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */, + 4B41EDB42B168C55001EEDF4 /* UnifiedFeedbackFormViewModel.swift in Sources */, AA7EB6E527E7D6DC00036718 /* AnimationView.swift in Sources */, 8562599A269CA0A600EE44BC /* NSRectExtension.swift in Sources */, 4B37EE5F2B4CFC3C00A89A61 /* HomePageRemoteMessagingStorage.swift in Sources */, @@ -11980,6 +12040,7 @@ 7B4D8A212BDA857300852966 /* VPNOperationErrorRecorder.swift in Sources */, 859F30642A72A7BB00C20372 /* BookmarksBarPromptPopover.swift in Sources */, 7B6545ED2C0778BB00115BEA /* VPNControllerUDSClient+ConvenienceInitializers.swift in Sources */, + BDBA85902C5D252A00BC54F5 /* VPNFeedbackSender.swift in Sources */, B693955426F04BEC0015B914 /* ColorView.swift in Sources */, AA5C1DD3285A217F0089850C /* RecentlyClosedCacheItem.swift in Sources */, B6BBF17427475B15004F850E /* PopupBlockedPopover.swift in Sources */, @@ -11996,6 +12057,7 @@ B68458B825C7E8B200DC17B6 /* Tab+NSSecureCoding.swift in Sources */, 85378DA0274E6F42007C5CBF /* NSNotificationName+EmailManager.swift in Sources */, 1DDC84FF2B835BC000670238 /* SearchPreferences.swift in Sources */, + BDBA85992C5D258100BC54F5 /* VPNFeedbackFormViewController.swift in Sources */, B693955726F04BEC0015B914 /* MouseOverButton.swift in Sources */, AA61C0D02722159B00E6B681 /* FireInfoViewController.swift in Sources */, 9D9AE8692AA76CDC0026E7DC /* LoginItem+NetworkProtection.swift in Sources */, @@ -12164,6 +12226,7 @@ 9F514F912B7D88AD001832A9 /* AddEditBookmarkFolderDialogView.swift in Sources */, 9FA173E32B7A12B600EE4E6E /* BookmarkDialogFolderManagementView.swift in Sources */, 37534CA8281198CD002621E7 /* AdjacentItemEnumerator.swift in Sources */, + BDBA85962C5D256C00BC54F5 /* VPNFeedbackFormView.swift in Sources */, 987799F62999996B005D8EB6 /* BookmarkDatabase.swift in Sources */, 7BB4BC6A2C5CD96200E06FC8 /* ActiveDomainPublisher.swift in Sources */, 4BE53374286E39F10019DBFD /* ChromiumKeychainPrompt.swift in Sources */, @@ -12301,7 +12364,7 @@ B6A5A2A025B96E8300AA7ADA /* AppStateChangePublisherTests.swift in Sources */, B63ED0E326B3E7FA00A9DAD1 /* CLLocationManagerMock.swift in Sources */, 37CD54BB27F25A4000F1F7B9 /* DownloadsPreferencesTests.swift in Sources */, - 4BE344EE2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift in Sources */, + 4BE344EE2B2376DF003FC223 /* UnifiedFeedbackFormViewModelTests.swift in Sources */, 9F872D9D2B9058D000138637 /* Bookmarks+TabTests.swift in Sources */, BBFF355D2C4AF26200DA3289 /* BookmarksSortModeTests.swift in Sources */, 9F3910622B68C35600CB5112 /* DownloadsTabExtensionTests.swift in Sources */, @@ -12331,6 +12394,7 @@ 4B98D27A28D95F1A003C2B6F /* ChromiumFaviconsReaderTests.swift in Sources */, 9F0660732BECC71200B8EEF1 /* SubscriptionAttributionPixelHandlerTests.swift in Sources */, 56A054302C2043C8007D8FAB /* OnboardingTabExtensionTests.swift in Sources */, + BDCB66D82C7CE1A600E8ABC9 /* VPNFeedbackFormViewModelTests.swift in Sources */, 986189E62A7CFB3E001B4519 /* LocalBookmarkStoreSavingTests.swift in Sources */, AA652CD325DDA6E9009059CC /* LocalBookmarkManagerTests.swift in Sources */, 1D232E992C7870D90043840D /* BinaryOwnershipCheckerTests.swift in Sources */, @@ -13473,7 +13537,7 @@ repositoryURL = "https://github.com/duckduckgo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 186.0.0; + version = 186.1.0; }; }; 9FF521422BAA8FF300B9819B /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 151e8e8b96..2b8ba57fc4 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/BrowserServicesKit", "state" : { - "revision" : "4a55217003ad7b2d44a1ac616d47596c0bda69dc", - "version" : "186.0.0" + "revision" : "606ccf9e86f5cad3ae83132f46241357feecf152", + "version" : "186.1.0" } }, { diff --git a/DuckDuckGo/Assets.xcassets/Images/PPro-Feedback.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/PPro-Feedback.imageset/Contents.json new file mode 100644 index 0000000000..b0631f4c9b --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/PPro-Feedback.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Privacy-Pro-16D.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/PPro-Feedback.imageset/Privacy-Pro-16D.svg b/DuckDuckGo/Assets.xcassets/Images/PPro-Feedback.imageset/Privacy-Pro-16D.svg new file mode 100644 index 0000000000..c64970b038 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/PPro-Feedback.imageset/Privacy-Pro-16D.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift index 26fabdff43..bec5c7538e 100644 --- a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift +++ b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift @@ -42,8 +42,8 @@ extension UserText { static let networkProtectionInviteSuccessMessage = "DuckDuckGo's VPN secures all of your device's Internet traffic anytime, anywhere." // MARK: - Navigation Bar Status View - // "network.protection.navbar.status.view.share.feedback" - Menu item for 'Share VPN Feedback' in the VPN status view that's shown in the navigation bar - static let networkProtectionNavBarStatusViewShareFeedback = "Share VPN Feedback…" + // "network.protection.navbar.status.view.send.feedback" - Menu item for 'Send Feedback' in the VPN status view that's shown in the navigation bar + static let networkProtectionNavBarStatusViewSendFeedback = "Send Feedback…" // "network.protection.status.menu.vpn.settings" - The status menu 'VPN Settings' menu item static let networkProtectionNavBarStatusMenuVPNSettings = "VPN Settings…" // "network.protection.status.menu.faq" - The status menu 'FAQ' menu item @@ -73,6 +73,24 @@ extension UserText { extension UserText { // MARK: - Feedback Form + // "feedback-form.title" - Title for each screen of the feedback form + static let feedbackFormTitle = "Help Improve Privacy Pro" + // "general.feedback-form.category.select-feature" - Title for the feature selection state of the general feedback form + static let generalFeedbackFormCategorySelect = "Select a category" + // "general.feedback-form.category.ppro" - Description for the feedback form when the issue is related to subscription and payments + static let generalFeedbackFormCategoryPPro = "Subscription and Payments" + // "general.feedback-form.category.vpn" - Description for the feedback form when the issue is related to VPN + static let generalFeedbackFormCategoryVPN = "VPN" + // "general.feedback-form.category.pir" - Description for the feedback form when the issue is related to Personal Info Removal (PIR) + static let generalFeedbackFormCategoryPIR = "Personal Info Removal" + // "general.feedback-form.category.itr" - Description for the feedback form when the issue is related to Identity Theft Restoration (ITR) + static let generalFeedbackFormCategoryITR = "Identity Theft Restoration" + // "ppro.feedback-form.category.select-category" - Title for the category selection state of the feedback form + static let pproFeedbackFormCategorySelect = "Select a category" + // "ppro.feedback-form.category.otp" - Description for the feedback form when there is an issue with the one-time password + static let pproFeedbackFormCategoryOTP = "Issue with one-time password" + // "ppro.feedback-form.category.something-else" - Description for the feedback form when the user has an issue not categorized in other options + static let pproFeedbackFormCategoryOther = "Something else" // "vpn.feedback-form.title" - Title for each screen of the VPN feedback form static let vpnFeedbackFormTitle = "Help Improve the DuckDuckGo VPN" // "vpn.feedback-form.category.select-category" - Title for the category selection state of the VPN feedback form @@ -93,7 +111,63 @@ extension UserText { static let vpnFeedbackFormCategoryFeatureRequest = "VPN feature request" // "vpn.feedback-form.category.other" - Title for the 'other VPN feedback' category of the VPN feedback form static let vpnFeedbackFormCategoryOther = "Other VPN feedback" + // "pir.feedback-form.category.select-category" - Title for the category selection state of the PIR feedback form + static let pirFeedbackFormCategorySelect = "Select a category" + // "pir.feedback-form.category.no-info-on-specific-site" - Description for the feedback form when the scan didn't find user's info on a specific site + static let pirFeedbackFormCategoryNothingOnSpecificSite = "The scan didn't find my info on a specific site" + // "pir.feedback-form.category.not-me" - Description for the feedback form when the scan found records that don’t belong to the user + static let pirFeedbackFormCategoryNotMe = "The scan found records which aren't me" + // "pir.feedback-form.category.scan-stuck" - Description for the feedback form when the scan process is stuck + static let pirFeedbackFormCategoryScanStuck = "The scan for records is stuck" + // "pir.feedback-form.category.removal-stuck" - Description for the feedback form when the removal process is stuck + static let pirFeedbackFormCategoryRemovalStuck = "The removal process is stuck" + // "itr.feedback-form.category.select-category" - Title for the category selection state of the ITR feedback form + static let itrFeedbackFormCategorySelect = "Select a category" + // "itr.feedback-form.category.access-code" - Description for the feedback form when there is an issue with the access code + static let itrFeedbackFormCategoryAccessCode = "Issue with access code" + // "itr.feedback-form.category.contact-advisor" - Description for the feedback form when the user is unable to contact an advisor + static let itrFeedbackFormCategoryCantContactAdvisor = "Unable to contact advisor" + // "itr.feedback-form.category.unhelpful" - Description for the feedback form when the call to an advisor was unhelpful + static let itrFeedbackFormCategoryUnhelpful = "Call to Advisor was unhelpful" + // "itr.feedback-form.category.something-else" - Description for the feedback form when the user has an issue not categorized in other options + static let itrFeedbackFormCategorySomethingElse = "Something else" + // "ppro.feedback-form.text-1" - Text for the body of the PPro feedback form + static let pproFeedbackFormText1 = "Found an issue not cover in our [help center](duck://)? We definitely want to know about it.\n\nTell us what's going on:" + // "ppro.feedback-form.text-2" - Text for the body of the PPro feedback form + static let pproFeedbackFormText2 = "In addition to the details entered above, we send some anonymized info with your feedback:" + // "ppro.feedback-form.text-3" - Bullet text for the body of the PPro feedback form + static let pproFeedbackFormText3 = "• Whether specific browser features are active" + // "ppro.feedback-form.text-4" - Bullet text for the body of the PPro feedback form + static let pproFeedbackFormText4 = "• Aggregate app diagnostics (e.g., error codes)" + // "ppro.feedback-form.text-5" - Text for the body of the PPro feedback form + static let pproFeedbackFormText5 = "By clicking \"Submit\" you agree that DuckDuckGo may use information submitted to improve the app." + // "ppro.feedback-form.disclaimer" - Text for the disclaimer of the PPro feedback form + static let pproFeedbackFormDisclaimer = "Reports are anonymous and sent to DuckDuckGo to help improve our service" + + // "ppro.feedback-form.sending-confirmation.title" - Title for the feedback sent view title of the feedback form + static let pproFeedbackFormSendingConfirmationTitle = "Thank you!" + // "ppro.feedback-form.sending-confirmation.description" - Title for the feedback sent view description of the feedback form + static let pproFeedbackFormSendingConfirmationDescription = "Your Feedback will help us improve Privacy Pro." + // "ppro.feedback-form.sending-confirmation.error" - Title for the feedback sending error text of the feedback form + static let pproFeedbackFormSendingConfirmationError = "We couldn't send your feedback right now, please try again." + + // "ppro.feedback-form.button.done" - Title for the Done button of the PPro feedback form + static let pproFeedbackFormButtonDone = "Done" + // "ppro.feedback-form.button.cancel" - Title for the Cancel button of the PPro feedback form + static let pproFeedbackFormButtonCancel = "Cancel" + // "ppro.feedback-form.button.submit" - Title for the Submit button of the PPro feedback form + static let pproFeedbackFormButtonSubmit = "Submit" + // "ppro.feedback-form.button.submitting" - Title for the Submitting state of the PPro feedback form + static let pproFeedbackFormButtonSubmitting = "Submitting…" + + // "ppro.feedback-form.general-feedback.placeholder" - Placeholder for the General Feedback step in the Privacy Pro feedback form + static let pproFeedbackFormGeneralFeedbackPlaceholder = "Please give us your feedback:" + // "ppro.feedback-form.request-feature.placeholder" - Placeholder for the Feature Request step in the Privacy Pro feedback form + static let pproFeedbackFormRequestFeaturePlaceholder = "What feature would you like to see?" + + // "pir.feedback-form.category.other" - Description for the feedback form when the user has an issue not categorized in other options + static let pirFeedbackFormCategoryOther = "Something else" // "vpn.feedback-form.text-1" - Text for the body of the VPN feedback form static let vpnFeedbackFormText1 = "Please describe what's happening, what you expected to happen, and the steps that led to the issue:" // "vpn.feedback-form.text-2" - Text for the body of the VPN feedback form diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index a922574c2f..b2dfd2c47c 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -492,6 +492,7 @@ struct UserText { static let bookmarkImportedFromFolder = NSLocalizedString("bookmarks.imported.from.folder", value: "Imported from", comment: "Name of the folder the imported bookmarks are saved into") // MARK: Feedback + static let sendPProFeedback = NSLocalizedString("send.ppro.feedback", value: "Send Privacy Pro Feedback", comment: "Menu with feedback commands") static let reportBrokenSite = NSLocalizedString("report.broken.site", value: "Report Broken Site", comment: "Menu with feedback commands") static let browserFeedback = NSLocalizedString("send.browser.feedback", value: "Send Browser Feedback", comment: "Menu with feedback commands") static let browserFeedbackTitle = NSLocalizedString("send.browser.feedback.title", value: "Help Improve the DuckDuckGo Browser", comment: "Title of the interface to send feedback on the browser") diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index 90c59a3d9d..ccd1671697 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -51960,6 +51960,18 @@ } } }, + "send.ppro.feedback" : { + "comment" : "Menu with feedback commands", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Send Privacy Pro Feedback" + } + } + } + }, "settings" : { "comment" : "Menu item for opening settings", "extractionState" : "extracted_with_value", @@ -58457,4 +58469,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 824d544a71..8204d364dd 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -190,6 +190,11 @@ extension AppDelegate { } } + @MainActor + @objc func openPProFeedback(_ sender: Any?) { + WindowControllersManager.shared.showShareFeedbackModal(source: .settings) + } + #endif @objc func navigateToBookmark(_ sender: Any?) { diff --git a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift index 085e84e1fa..2daca8b0b9 100644 --- a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift +++ b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift @@ -108,7 +108,10 @@ final class MoreOptionsMenu: NSMenu { let feedbackMenuItem = NSMenuItem(title: feedbackString, action: nil, keyEquivalent: "") .withImage(.sendFeedback) - feedbackMenuItem.submenu = FeedbackSubMenu(targetting: self, tabCollectionViewModel: tabCollectionViewModel) + feedbackMenuItem.submenu = FeedbackSubMenu(targetting: self, + tabCollectionViewModel: tabCollectionViewModel, + subscriptionFeatureAvailability: subscriptionFeatureAvailability, + accountManager: accountManager) addItem(feedbackMenuItem) addItem(NSMenuItem.separator()) @@ -488,8 +491,15 @@ final class EmailOptionsButtonSubMenu: NSMenu { @MainActor final class FeedbackSubMenu: NSMenu { + private let subscriptionFeatureAvailability: SubscriptionFeatureAvailability + private let accountManager: AccountManager - init(targetting target: AnyObject, tabCollectionViewModel: TabCollectionViewModel) { + init(targetting target: AnyObject, + tabCollectionViewModel: TabCollectionViewModel, + subscriptionFeatureAvailability: SubscriptionFeatureAvailability, + accountManager: AccountManager) { + self.subscriptionFeatureAvailability = subscriptionFeatureAvailability + self.accountManager = accountManager super.init(title: UserText.sendFeedback) updateMenuItems(with: tabCollectionViewModel, targetting: target) } @@ -512,6 +522,16 @@ final class FeedbackSubMenu: NSMenu { keyEquivalent: "") .withImage(.siteBreakage) addItem(reportBrokenSiteItem) + + if subscriptionFeatureAvailability.usesUnifiedFeedbackForm, accountManager.isUserAuthenticated { + addItem(.separator()) + + let sendPProFeedbackItem = NSMenuItem(title: UserText.sendPProFeedback, + action: #selector(AppDelegate.openPProFeedback(_:)), + keyEquivalent: "") + .withImage(.pProFeedback) + addItem(sendPProFeedbackItem) + } } } diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index ed07f0746a..6cf1768160 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -95,6 +95,7 @@ final class NavigationBarViewController: NSViewController { private var selectedTabViewModelCancellable: AnyCancellable? private var credentialsToSaveCancellable: AnyCancellable? private var vpnToggleCancellable: AnyCancellable? + private var feedbackFormCancellable: AnyCancellable? private var passwordManagerNotificationCancellable: AnyCancellable? private var pinnedViewsNotificationCancellable: AnyCancellable? private var navigationButtonsCancellables = Set() @@ -151,6 +152,7 @@ final class NavigationBarViewController: NSViewController { listenToPasswordManagerNotifications() listenToPinningManagerNotifications() listenToMessageNotifications() + listenToFeedbackFormNotifications() subscribeToDownloads() addContextMenu() @@ -404,6 +406,12 @@ final class NavigationBarViewController: NSViewController { .store(in: &cancellables) } + func listenToFeedbackFormNotifications() { + feedbackFormCancellable = NotificationCenter.default.publisher(for: .OpenUnifiedFeedbackForm).receive(on: DispatchQueue.main).sink { _ in + WindowControllersManager.shared.showShareFeedbackModal(source: .ppro) + } + } + @objc private func showVPNUninstalledFeedback() { // Only show the popover if we aren't already presenting one: guard view.window?.isKeyWindow == true, (self.presentedViewControllers ?? []).isEmpty else { return } @@ -1169,4 +1177,5 @@ extension NavigationBarViewController { extension Notification.Name { static let ToggleNetworkProtectionInMainWindow = Notification.Name("com.duckduckgo.vpn.toggle-popover-in-main-window") + static let OpenUnifiedFeedbackForm = Notification.Name("com.duckduckgo.subscription.open-unified-feedback-form") } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift index 6573e3a550..531fbea107 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift @@ -128,7 +128,7 @@ final class NetworkProtectionNavBarPopoverManager: NetPPopoverManager { try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showFAQ) }), NetworkProtectionStatusView.Model.MenuItem( - name: UserText.networkProtectionNavBarStatusViewShareFeedback, + name: UserText.networkProtectionNavBarStatusViewSendFeedback, action: { try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.shareFeedback) }) @@ -140,7 +140,7 @@ final class NetworkProtectionNavBarPopoverManager: NetPPopoverManager { try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showFAQ) }), NetworkProtectionStatusView.Model.MenuItem( - name: UserText.networkProtectionNavBarStatusViewShareFeedback, + name: UserText.networkProtectionNavBarStatusViewSendFeedback, action: { try? await appLauncher.launchApp(withCommand: VPNAppLaunchCommand.shareFeedback) }) diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNURLEventHandler.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNURLEventHandler.swift index 54368bc09e..3ca5bb8fef 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNURLEventHandler.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNURLEventHandler.swift @@ -63,7 +63,7 @@ final class VPNURLEventHandler { } func showShareFeedback() { - windowControllerManager.showShareFeedbackModal() + windowControllerManager.showShareFeedbackModal(source: .vpn) } func showMainWindow() { diff --git a/DuckDuckGo/Preferences/View/PreferencesRootView.swift b/DuckDuckGo/Preferences/View/PreferencesRootView.swift index ab2a539db6..b559d4a6da 100644 --- a/DuckDuckGo/Preferences/View/PreferencesRootView.swift +++ b/DuckDuckGo/Preferences/View/PreferencesRootView.swift @@ -104,7 +104,8 @@ enum Preferences { case .vpn: VPNView(model: VPNPreferencesModel(), status: model.vpnProtectionStatus()) case .subscription: - SubscriptionUI.PreferencesSubscriptionView(model: subscriptionModel!) + SubscriptionUI.PreferencesSubscriptionView(model: subscriptionModel!, + subscriptionFeatureAvailability: DefaultSubscriptionFeatureAvailability()) case .autofill: AutofillView(model: AutofillPreferencesModel()) case .accessibility: @@ -136,6 +137,8 @@ enum Preferences { case .openVPN: PixelKit.fire(PrivacyProPixel.privacyProVPNSettings) NotificationCenter.default.post(name: .ToggleNetworkProtectionInMainWindow, object: self, userInfo: nil) + case .openFeedback: + NotificationCenter.default.post(name: .OpenUnifiedFeedbackForm, object: self, userInfo: nil) case .openDB: PixelKit.fire(PrivacyProPixel.privacyProPersonalInformationRemovalSettings) WindowControllersManager.shared.showTab(with: .dataBrokerProtection) diff --git a/DuckDuckGo/Statistics/GeneralPixel.swift b/DuckDuckGo/Statistics/GeneralPixel.swift index 0fd4143a3a..78b911cb8b 100644 --- a/DuckDuckGo/Statistics/GeneralPixel.swift +++ b/DuckDuckGo/Statistics/GeneralPixel.swift @@ -140,6 +140,15 @@ enum GeneralPixel: PixelKitEventV2 { // VPN case vpnBreakageReport(category: String, description: String, metadata: String) + // Unified Feedback + 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 pproFeedbackSubmitScreenShow(source: String, reportType: String, category: String, subcategory: String) + case pproFeedbackSubmitScreenFAQClick(source: String, reportType: String, category: String, subcategory: String) + case networkProtectionEnabledOnSearch case networkProtectionGeoswitchingOpened case networkProtectionGeoswitchingSetNearest @@ -625,6 +634,19 @@ enum GeneralPixel: PixelKitEventV2 { case .vpnBreakageReport: return "m_mac_vpn_breakage_report" + case .pproFeedbackFeatureRequest: + return "m_mac_ppro_feedback_feature-request" + case .pproFeedbackGeneralFeedback: + return "m_mac_ppro_feedback_general-feedback" + case .pproFeedbackReportIssue: + return "m_mac_ppro_feedback_report-issue" + case .pproFeedbackFormShow: + return "m_mac_ppro_feedback_general-screen_show" + case .pproFeedbackSubmitScreenShow: + return "m_mac_ppro_feedback_submit-screen_show" + case .pproFeedbackSubmitScreenFAQClick: + return "m_mac_ppro_feedback_submit-screen-faq_click" + case .networkProtectionEnabledOnSearch: return "m_mac_netp_ev_enabled_on_search" @@ -1078,6 +1100,39 @@ enum GeneralPixel: PixelKitEventV2 { PixelKit.Parameters.vpnBreakageMetadata: metadata ] + case .pproFeedbackFeatureRequest(let description, let source): + return [ + PixelKit.Parameters.pproIssueDescription: description, + PixelKit.Parameters.pproIssueSource: source, + ] + case .pproFeedbackGeneralFeedback(let description, let source): + return [ + PixelKit.Parameters.pproIssueDescription: description, + PixelKit.Parameters.pproIssueSource: source, + ] + case .pproFeedbackReportIssue(let source, let category, let subcategory, let description, let metadata): + return [ + PixelKit.Parameters.pproIssueSource: source, + PixelKit.Parameters.pproIssueCategory: category, + PixelKit.Parameters.pproIssueSubcategory: subcategory, + PixelKit.Parameters.pproIssueDescription: description, + PixelKit.Parameters.pproIssueMetadata: metadata, + ] + case .pproFeedbackSubmitScreenShow(let source, let reportType, let category, let subcategory): + return [ + PixelKit.Parameters.pproIssueSource: source, + PixelKit.Parameters.pproIssueReportType: reportType, + PixelKit.Parameters.pproIssueCategory: category, + PixelKit.Parameters.pproIssueSubcategory: subcategory, + ] + case .pproFeedbackSubmitScreenFAQClick(let source, let reportType, let category, let subcategory): + return [ + PixelKit.Parameters.pproIssueSource: source, + PixelKit.Parameters.pproIssueReportType: reportType, + PixelKit.Parameters.pproIssueCategory: category, + PixelKit.Parameters.pproIssueSubcategory: subcategory, + ] + case .onboardingCohortAssigned(let cohort): return [PixelKit.Parameters.experimentCohort: cohort] case .onboardingHomeButtonEnabled(let cohort): diff --git a/DuckDuckGo/UnifiedFeedbackForm/FeedbackCategoryProviding.swift b/DuckDuckGo/UnifiedFeedbackForm/FeedbackCategoryProviding.swift new file mode 100644 index 0000000000..da53250652 --- /dev/null +++ b/DuckDuckGo/UnifiedFeedbackForm/FeedbackCategoryProviding.swift @@ -0,0 +1,198 @@ +// +// FeedbackCategoryProviding.swift +// +// 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 { + static var prompt: Self { get } + var displayName: String { get } +} + +protocol FeedbackFAQProviding { + var url: URL? { get } +} + +extension FeedbackCategoryProviding where RawValue == String { + var id: String { + rawValue + } +} + +enum UnifiedFeedbackReportType: String, FeedbackCategoryProviding { + case selectReportType + case reportIssue + case requestFeature + case general + + static var prompt = UnifiedFeedbackReportType.selectReportType + + var displayName: String { + switch self { + case .selectReportType: return UserText.browserFeedbackSelectCategory + case .reportIssue: return UserText.browserFeedbackReportProblem + case .requestFeature: return UserText.browserFeedbackRequestFeature + case .general: return UserText.browserFeedbackGeneralFeedback + } + } +} + +enum UnifiedFeedbackCategory: String, FeedbackCategoryProviding { + case selectFeature + case subscription + case vpn + case pir + case itr + + static var prompt = UnifiedFeedbackCategory.selectFeature + + var displayName: String { + switch self { + case .selectFeature: return UserText.generalFeedbackFormCategorySelect + 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 selectSubcategory + case otp + case somethingElse + + static var prompt = PrivacyProFeedbackSubcategory.selectSubcategory + + var displayName: String { + switch self { + case .selectSubcategory: return UserText.pproFeedbackFormCategorySelect + case .otp: return UserText.pproFeedbackFormCategoryOTP + case .somethingElse: return UserText.pproFeedbackFormCategoryOther + } + } + + var url: URL? { + switch self { + case .selectSubcategory: return nil + 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 selectSubcategory + case unableToInstall + case failsToConnect + case tooSlow + case issueWithAppOrWebsite + case appCrashesOrFreezes + case cantConnectToLocalDevice + case somethingElse + + static var prompt = VPNFeedbackSubcategory.selectSubcategory + + var displayName: String { + switch self { + case .selectSubcategory: return UserText.vpnFeedbackFormCategorySelect + 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 .selectSubcategory: return nil + 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 selectSubcategory + case nothingOnSpecificSite + case notMe + case scanStuck + case removalStuck + case somethingElse + + static var prompt = PIRFeedbackSubcategory.selectSubcategory + + var displayName: String { + switch self { + case .selectSubcategory: return UserText.pirFeedbackFormCategorySelect + 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 .selectSubcategory: return nil + 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 selectSubcategory + case accessCode + case cantContactAdvisor + case advisorUnhelpful + case somethingElse + + static var prompt = ITRFeedbackSubcategory.selectSubcategory + + var displayName: String { + switch self { + case .selectSubcategory: return UserText.itrFeedbackFormCategorySelect + 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 .selectSubcategory: return nil + 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/UnifiedFeedbackForm/MetadataCollectors/EmptyMetadataCollector.swift b/DuckDuckGo/UnifiedFeedbackForm/MetadataCollectors/EmptyMetadataCollector.swift new file mode 100644 index 0000000000..b8af990ffa --- /dev/null +++ b/DuckDuckGo/UnifiedFeedbackForm/MetadataCollectors/EmptyMetadataCollector.swift @@ -0,0 +1,32 @@ +// +// EmptyMetadataCollector.swift +// +// 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 EmptyFeedbackMetadata: 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 EmptyMetadataCollector: UnifiedMetadataCollector { + func collectMetadata() async -> EmptyFeedbackMetadata { + EmptyFeedbackMetadata() + } +} diff --git a/DuckDuckGo/UnifiedFeedbackForm/MetadataCollectors/UnifiedMetadataCollector.swift b/DuckDuckGo/UnifiedFeedbackForm/MetadataCollectors/UnifiedMetadataCollector.swift new file mode 100644 index 0000000000..a720ac8bdc --- /dev/null +++ b/DuckDuckGo/UnifiedFeedbackForm/MetadataCollectors/UnifiedMetadataCollector.swift @@ -0,0 +1,42 @@ +// +// UnifiedMetadataCollector.swift +// +// 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/UnifiedFeedbackForm/UnifiedFeedbackFormView.swift b/DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackFormView.swift new file mode 100644 index 0000000000..011f00efc9 --- /dev/null +++ b/DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackFormView.swift @@ -0,0 +1,310 @@ +// +// UnifiedFeedbackFormView.swift +// +// 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 + +struct UnifiedFeedbackFormView: View { + + @EnvironmentObject var viewModel: UnifiedFeedbackFormViewModel + + var body: some View { + VStack(spacing: 0) { + Group { + Text(UserText.feedbackFormTitle) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.secondary) + } + .frame(height: 70) + .frame(maxWidth: .infinity) + .background(Color.secondary.opacity(0.1)) + + Divider() + + switch viewModel.viewState { + case .feedbackPending, .feedbackSending, .feedbackSendingFailed: + FeedbackFormBodyView() + .padding([.top, .leading, .trailing], 20) + + if viewModel.viewState == .feedbackSendingFailed { + Text(UserText.vpnFeedbackFormSendingConfirmationError) + .foregroundColor(.red) + .padding(.top, 15) + } + case .feedbackSent: + FeedbackFormSentView() + .padding([.top, .leading, .trailing], 20) + } + + Spacer(minLength: 0) + + FeedbackFormButtons() + .padding(20) + } + .onChange(of: viewModel.needsSubmitShowReport) { needsSubmitShowReport in + if needsSubmitShowReport { + Task { + await viewModel.process(action: .reportSubmitShow) + } + } + } + .task { + await viewModel.process(action: .reportShow) + } + } + +} + +private struct FeedbackFormBodyView: View { + + @EnvironmentObject var viewModel: UnifiedFeedbackFormViewModel + + var body: some View { + CategoryPicker(sources: UnifiedFeedbackReportType.self, selection: $viewModel.selectedReportType) { + switch UnifiedFeedbackReportType(rawValue: viewModel.selectedReportType) { + case .selectReportType, nil: + EmptyView() + case .general: + FeedbackFormIssueDescriptionView { + Text(UserText.pproFeedbackFormGeneralFeedbackPlaceholder) + } + case .requestFeature: + FeedbackFormIssueDescriptionView { + Text(UserText.pproFeedbackFormRequestFeaturePlaceholder) + } + case .reportIssue: + reportProblemView() + } + } + } + + @ViewBuilder + func reportProblemView() -> some View { + CategoryPicker(sources: UnifiedFeedbackCategory.self, selection: $viewModel.selectedCategory) { + switch UnifiedFeedbackCategory(rawValue: viewModel.selectedCategory) { + case .selectFeature, nil: + EmptyView() + case .subscription: + CategoryPicker(sources: PrivacyProFeedbackSubcategory.self, selection: $viewModel.selectedSubcategory) { + issueDescriptionView() + } + case .vpn: + CategoryPicker(sources: VPNFeedbackSubcategory.self, selection: $viewModel.selectedSubcategory) { + issueDescriptionView() + } + case .pir: + CategoryPicker(sources: PIRFeedbackSubcategory.self, selection: $viewModel.selectedSubcategory) { + issueDescriptionView() + } + case .itr: + CategoryPicker(sources: ITRFeedbackSubcategory.self, selection: $viewModel.selectedSubcategory) { + issueDescriptionView() + } + } + } + } + + @ViewBuilder + func issueDescriptionView() -> some View { + FeedbackFormIssueDescriptionView { + Text(LocalizedStringKey(UserText.pproFeedbackFormText1)) + .onURLTap { _ in + Task { + await viewModel.process(action: .reportFAQClick) + await viewModel.process(action: .faqClick) + } + } + } footer: { + Text(UserText.pproFeedbackFormText2) + VStack(alignment: .leading) { + Text(UserText.pproFeedbackFormText3) + Text(UserText.pproFeedbackFormText4) + } + Text(UserText.pproFeedbackFormText5) + } + } +} + +private struct CategoryPicker: View where Category.AllCases == [Category], Category.RawValue == String { + let sources: Category.Type + let selection: Binding + let content: () -> Content + + init(sources: Category.Type, + selection: Binding, + @ViewBuilder content: @escaping () -> Content) { + self.sources = sources + self.selection = selection + self.content = content + } + + var body: some View { + Group { + Picker(selection: selection, content: { + ForEach(sources.allCases) { option in + Text(option.displayName).tag(option.rawValue) + } + }, label: {}) + .controlSize(.large) + .padding(.bottom, 0) + + if Category(rawValue: selection.wrappedValue) == .prompt { + Spacer() + .frame(height: 50) + } else { + content() + } + } + } +} + +private struct FeedbackFormIssueDescriptionView: View { + @EnvironmentObject var viewModel: UnifiedFeedbackFormViewModel + + let label: () -> Label + let content: () -> Content + let footer: () -> Footer + + init(@ViewBuilder label: @escaping () -> Label, + @ViewBuilder content: @escaping () -> Content, + @ViewBuilder footer: @escaping () -> Footer) { + self.label = label + self.content = content + self.footer = footer + } + + init(@ViewBuilder label: @escaping () -> Label, + @ViewBuilder footer: @escaping () -> Footer) where Content == EmptyView { + self.init { + label() + } content: { + EmptyView() + } footer: { + footer() + } + } + + init(@ViewBuilder label: @escaping () -> Label) where Content == EmptyView, Footer == Text { + self.init { + label() + } content: { + EmptyView() + } footer: { + Text(UserText.pproFeedbackFormDisclaimer) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + label() + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + textEditor() + content() + footer() + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.secondary) + } + } + + @ViewBuilder + func textEditor() -> some View { +#if APPSTORE + FocusableTextEditor(text: $viewModel.feedbackFormText, characterLimit: 1000) +#else + if #available(macOS 12, *) { + FocusableTextEditor(text: $viewModel.feedbackFormText, characterLimit: 1000) + } else { + TextEditor(text: $viewModel.feedbackFormText) + .frame(height: 197.0) + .font(.body) + .foregroundColor(.primary) + .onChange(of: viewModel.feedbackFormText) { + viewModel.feedbackFormText = String($0.prefix(1000)) + } + .padding(EdgeInsets(top: 3.0, leading: 6.0, bottom: 5.0, trailing: 0.0)) + .clipShape(RoundedRectangle(cornerRadius: 8.0, style: .continuous)) + .background( + ZStack { + RoundedRectangle(cornerRadius: 8.0) + .stroke(Color(.textEditorBorder), lineWidth: 0.4) + RoundedRectangle(cornerRadius: 8.0) + .fill(Color(.textEditorBackground)) + } + ) + } +#endif + } +} + +private struct FeedbackFormSentView: View { + + var body: some View { + VStack(spacing: 0) { + Image(.vpnFeedbackSent) + .padding(.top, 20) + + Text(UserText.pproFeedbackFormSendingConfirmationTitle) + .font(.system(size: 18, weight: .medium)) + .padding(.top, 30) + + Text(UserText.pproFeedbackFormSendingConfirmationDescription) + .multilineTextAlignment(.center) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 10) + } + } + +} + +private struct FeedbackFormButtons: View { + + @EnvironmentObject var viewModel: UnifiedFeedbackFormViewModel + + var body: some View { + HStack { + if viewModel.viewState == .feedbackSent { + button(text: UserText.pproFeedbackFormButtonDone, action: .cancel) + .keyboardShortcut(.defaultAction) + } else { + button(text: UserText.pproFeedbackFormButtonCancel, action: .cancel) + button(text: viewModel.viewState == .feedbackSending ? UserText.pproFeedbackFormButtonSubmitting : UserText.pproFeedbackFormButtonSubmit, action: .submit) + .keyboardShortcut(.defaultAction) + .disabled(!viewModel.submitButtonEnabled) + } + } + } + + @ViewBuilder + func button(text: String, action: UnifiedFeedbackFormViewModel.ViewAction) -> some View { + Button(action: { + Task { + await viewModel.process(action: action) + } + }, label: { + Text(text) + .frame(maxWidth: .infinity) + }) + .controlSize(.large) + .frame(maxWidth: .infinity) + } + +} diff --git a/DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackFormViewController.swift b/DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackFormViewController.swift new file mode 100644 index 0000000000..8fa9d63fdc --- /dev/null +++ b/DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackFormViewController.swift @@ -0,0 +1,129 @@ +// +// UnifiedFeedbackFormViewController.swift +// +// Copyright © 2023 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 AppKit +import SwiftUI +import Combine +import PixelKit + +final class UnifiedFeedbackFormViewController: NSViewController { + // Using a dynamic height in the form was causing layout problems and couldn't be completed in time for the release that needed this form. + // As a temporary measure, the heights of each form state are hardcoded. + // This should be cleaned up later, and eventually use the `sizingOptions` property of NSHostingController. + enum Constants { + static let landingPageHeight = 260.0 + static let feedbackFormCompactHeight = 430.0 + static let feedbackFormHeight = 650.0 + static let feedbackSentHeight = 350.0 + static let feedbackErrorHeight = 560.0 + } + + private let defaultSize = CGSize(width: 480, height: Constants.landingPageHeight) + + private let feedbackSender: UnifiedFeedbackSender + private let viewModel: UnifiedFeedbackFormViewModel + + private var heightConstraint: NSLayoutConstraint? + private var cancellables = Set() + + init(feedbackSender: UnifiedFeedbackSender = DefaultFeedbackSender(), + source: UnifiedFeedbackSource = .default) { + self.feedbackSender = feedbackSender + self.viewModel = UnifiedFeedbackFormViewModel( + vpnMetadataCollector: DefaultVPNMetadataCollector(accountManager: Application.appDelegate.subscriptionManager.accountManager), + feedbackSender: feedbackSender, + source: source + ) + super.init(nibName: nil, bundle: nil) + self.viewModel.delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = NSView(frame: NSRect(origin: CGPoint.zero, size: defaultSize)) + } + + override func viewDidLoad() { + super.viewDidLoad() + + let feedbackFormView = UnifiedFeedbackFormView() + let hostingView = NSHostingView(rootView: feedbackFormView.environmentObject(self.viewModel)) + hostingView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingView) + + let heightConstraint = hostingView.heightAnchor.constraint(equalToConstant: defaultSize.height) + self.heightConstraint = heightConstraint + + NSLayoutConstraint.activate([ + heightConstraint, + hostingView.widthAnchor.constraint(equalToConstant: defaultSize.width), + hostingView.topAnchor.constraint(equalTo: view.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingView.leftAnchor.constraint(equalTo: view.leftAnchor), + hostingView.rightAnchor.constraint(equalTo: view.rightAnchor) + ]) + + subscribeToViewModelChanges() + } + + func subscribeToViewModelChanges() { + viewModel.$viewState + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateViewHeight() + } + .store(in: &cancellables) + + viewModel.$selectedReportType + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateViewHeight() + } + .store(in: &cancellables) + } + + private func updateViewHeight() { + switch viewModel.viewState { + case .feedbackPending: + if UnifiedFeedbackReportType(rawValue: viewModel.selectedReportType) == .prompt { + heightConstraint?.constant = Constants.landingPageHeight + } else { + heightConstraint?.constant = viewModel.usesCompactForm ? Constants.feedbackFormCompactHeight : Constants.feedbackFormHeight + } + case .feedbackSending: + heightConstraint?.constant = viewModel.usesCompactForm ? Constants.feedbackFormCompactHeight : Constants.feedbackFormHeight + case .feedbackSent: + heightConstraint?.constant = Constants.feedbackSentHeight + case .feedbackSendingFailed: + heightConstraint?.constant = Constants.feedbackErrorHeight + } + } + +} + +extension UnifiedFeedbackFormViewController: UnifiedFeedbackFormViewModelDelegate { + + func feedbackViewModelDismissedView(_ viewModel: UnifiedFeedbackFormViewModel) { + dismiss() + } + +} diff --git a/DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackFormViewModel.swift b/DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackFormViewModel.swift new file mode 100644 index 0000000000..5d89a79282 --- /dev/null +++ b/DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackFormViewModel.swift @@ -0,0 +1,238 @@ +// +// UnifiedFeedbackFormViewModel.swift +// +// Copyright © 2023 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 Combine +import SwiftUI +import PixelKit + +protocol UnifiedFeedbackFormViewModelDelegate: AnyObject { + func feedbackViewModelDismissedView(_ viewModel: UnifiedFeedbackFormViewModel) +} + +final class UnifiedFeedbackFormViewModel: ObservableObject { + enum ViewState { + case feedbackPending + case feedbackSending + case feedbackSendingFailed + case feedbackSent + + var canSubmit: Bool { + switch self { + case .feedbackPending: return true + case .feedbackSending: return false + case .feedbackSendingFailed: return true + case .feedbackSent: return false + } + } + } + + enum ViewAction { + case cancel + case submit + case faqClick + case reportShow + case reportSubmitShow + case reportFAQClick + } + + @Published var viewState: ViewState { + didSet { + updateSubmitButtonStatus() + } + } + + @Published var feedbackFormText: String = "" { + didSet { + updateSubmitButtonStatus() + } + } + + @Published private(set) var submitButtonEnabled: Bool = false + @Published var selectedReportType: String = UnifiedFeedbackReportType.prompt.rawValue { + didSet { + let defaultCategory: UnifiedFeedbackCategory + switch source { + case .ppro: defaultCategory = .subscription + case .vpn: defaultCategory = .vpn + case .pir: defaultCategory = .pir + case .itr: defaultCategory = .itr + default: defaultCategory = .prompt + } + selectedCategory = defaultCategory.rawValue + updateSubmitShowStatus() + } + } + @Published var selectedCategory: String = UnifiedFeedbackCategory.prompt.rawValue { + didSet { + selectedSubcategory = selectedSubcategoryPrompt + updateSubmitShowStatus() + } + } + @Published var selectedSubcategory = "" { + didSet { + updateSubmitShowStatus() + } + } + + private var selectedSubcategoryPrompt: String { + switch UnifiedFeedbackCategory(rawValue: selectedCategory) { + case .selectFeature, nil: return "" + case .subscription: return PrivacyProFeedbackSubcategory.prompt.rawValue + case .vpn: return VPNFeedbackSubcategory.prompt.rawValue + case .pir: return PIRFeedbackSubcategory.prompt.rawValue + case .itr: return ITRFeedbackSubcategory.prompt.rawValue + } + } + + @Published var needsSubmitShowReport = false + + var usesCompactForm: Bool { + switch UnifiedFeedbackReportType(rawValue: selectedReportType) { + case .reportIssue: + return false + default: + return true + } + } + + weak var delegate: UnifiedFeedbackFormViewModelDelegate? + + private let vpnMetadataCollector: any UnifiedMetadataCollector + private let defaultMetadataCollector: any UnifiedMetadataCollector + private let feedbackSender: any UnifiedFeedbackSender + + let source: UnifiedFeedbackSource + + init(vpnMetadataCollector: any UnifiedMetadataCollector, + defaultMetadataCollector: any UnifiedMetadataCollector = EmptyMetadataCollector(), + feedbackSender: any UnifiedFeedbackSender = DefaultFeedbackSender(), + source: UnifiedFeedbackSource = .default) { + self.viewState = .feedbackPending + + self.vpnMetadataCollector = vpnMetadataCollector + self.defaultMetadataCollector = defaultMetadataCollector + self.feedbackSender = feedbackSender + self.source = source + } + + @MainActor + func process(action: ViewAction) async { + switch action { + case .cancel: + delegate?.feedbackViewModelDismissedView(self) + case .submit: + self.viewState = .feedbackSending + + do { + try await sendFeedback() + self.viewState = .feedbackSent + } catch { + self.viewState = .feedbackSendingFailed + } + case .faqClick: + await openFAQ() + case .reportShow: + feedbackSender.sendFormShowPixel() + case .reportSubmitShow: + feedbackSender.sendSubmitScreenShowPixel(source: source, + reportType: selectedReportType, + category: selectedCategory, + subcategory: selectedSubcategory) + needsSubmitShowReport = false + case .reportFAQClick: + feedbackSender.sendSubmitScreenFAQClickPixel(source: source, + reportType: selectedReportType, + category: selectedCategory, + subcategory: selectedSubcategory) + } + } + + private func openFAQ() async { + guard !selectedReportType.isEmpty, UnifiedFeedbackReportType(rawValue: selectedReportType) == .reportIssue, + !selectedCategory.isEmpty, let category = UnifiedFeedbackCategory(rawValue: selectedCategory), + !selectedSubcategory.isEmpty else { + return + } + + let url: URL? = { + switch category { + case .selectFeature: return nil + 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 { + NSWorkspace.shared.open(url) + } + } + + private func sendFeedback() async throws { + switch UnifiedFeedbackReportType(rawValue: selectedReportType) { + case .selectReportType, 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 { + 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? EmptyFeedbackMetadata) + } + } + + private func updateSubmitButtonStatus() { + self.submitButtonEnabled = viewState.canSubmit && !feedbackFormText.isEmpty + } + + private func updateSubmitShowStatus() { + needsSubmitShowReport = { + switch UnifiedFeedbackReportType(rawValue: selectedReportType) { + case .selectReportType, nil: + return false + case .requestFeature, .general: + return true + case .reportIssue: + return selectedCategory != UnifiedFeedbackCategory.prompt.rawValue && selectedSubcategory != selectedSubcategoryPrompt + } + }() + } +} diff --git a/DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackSender.swift b/DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackSender.swift new file mode 100644 index 0000000000..edbb839d04 --- /dev/null +++ b/DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackSender.swift @@ -0,0 +1,118 @@ +// +// UnifiedFeedbackSender.swift +// +// Copyright © 2023 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 PixelKit + +enum UnifiedFeedbackSource: String, StringRepresentable { + case settings, ppro, vpn, pir, itr, unknown + static var `default` = UnifiedFeedbackSource.unknown +} + +protocol UnifiedFeedbackSender { + func sendFeatureRequestPixel(description: String, source: UnifiedFeedbackSource) async throws + func sendGeneralFeedbackPixel(description: String, source: UnifiedFeedbackSource) async throws + func sendReportIssuePixel(source: UnifiedFeedbackSource, category: String, subcategory: String, description: String, metadata: T?) async throws + + func sendFormShowPixel() + func sendSubmitScreenShowPixel(source: UnifiedFeedbackSource, reportType: String, category: String, subcategory: String) + func sendSubmitScreenFAQClickPixel(source: UnifiedFeedbackSource, reportType: String, category: String, subcategory: String) +} + +extension UnifiedFeedbackSender { + func sendStandardPixel(_ pixel: PixelKitEventV2) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + PixelKit.fire(pixel, frequency: .standard) { _, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } +} + +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 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: UnifiedFeedbackSource) async throws { + try await sendStandardPixel(GeneralPixel.pproFeedbackFeatureRequest(description: description, + source: source.rawValue)) + } + + func sendGeneralFeedbackPixel(description: String, source: UnifiedFeedbackSource) async throws { + try await sendStandardPixel(GeneralPixel.pproFeedbackGeneralFeedback(description: description, + source: source.rawValue)) + } + + func sendReportIssuePixel(source: UnifiedFeedbackSource, category: String, subcategory: String, description: String, metadata: T?) async throws { + try await sendStandardPixel(GeneralPixel.pproFeedbackReportIssue(source: source.rawValue, + category: Category.from(category), + subcategory: Subcategory.from(subcategory), + description: description, + metadata: metadata?.toBase64() ?? "")) + } + + func sendFormShowPixel() { + PixelKit.fire(GeneralPixel.pproFeedbackFormShow, frequency: .dailyAndCount) + } + + func sendSubmitScreenShowPixel(source: UnifiedFeedbackSource, reportType: String, category: String, subcategory: String) { + PixelKit.fire(GeneralPixel.pproFeedbackSubmitScreenShow(source: source.rawValue, + reportType: ReportType.from(reportType), + category: Category.from(category), + subcategory: Subcategory.from(subcategory)), + frequency: .dailyAndCount) + } + + func sendSubmitScreenFAQClickPixel(source: UnifiedFeedbackSource, reportType: String, category: String, subcategory: String) { + PixelKit.fire(GeneralPixel.pproFeedbackSubmitScreenFAQClick(source: source.rawValue, + reportType: ReportType.from(reportType), + category: Category.from(category), + subcategory: Subcategory.from(subcategory)), + frequency: .dailyAndCount) + } +} diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index fb3f6ccf4b..a34ccfcdc1 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -39,7 +39,7 @@ struct VPNFeedbackFormView: View { switch viewModel.viewState { case .feedbackPending, .feedbackSending, .feedbackSendingFailed: VPNFeedbackFormBodyView() - .padding([.top, .leading, .trailing], 20) + .padding([.top, .leading, .trailing], 20) if viewModel.viewState == .feedbackSendingFailed { Text(UserText.vpnFeedbackFormSendingConfirmationError) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift index 590d958513..22e38142a2 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift @@ -81,15 +81,15 @@ final class VPNFeedbackFormViewController: NSViewController { .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateViewHeight() - } - .store(in: &cancellables) + } + .store(in: &cancellables) viewModel.$selectedFeedbackCategory .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateViewHeight() - } - .store(in: &cancellables) + } + .store(in: &cancellables) } private func updateViewHeight() { diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift index 70aeb3e110..3fff69c5d2 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift @@ -85,7 +85,7 @@ final class VPNFeedbackFormViewModel: ObservableObject { self.viewState = .feedbackSending do { - let metadata = await self.metadataCollector.collectMetadata() + let metadata = await self.metadataCollector.collectVPNMetadata() try await self.feedbackSender.send(metadata: metadata, category: selectedFeedbackCategory, userText: feedbackFormText) self.viewState = .feedbackSent } catch { diff --git a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift index 1c27d86d1c..778b039358 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift @@ -115,7 +115,7 @@ struct VPNMetadata: Encodable { } protocol VPNMetadataCollector { - func collectMetadata() async -> VPNMetadata + func collectVPNMetadata() async -> VPNMetadata } final class DefaultVPNMetadataCollector: VPNMetadataCollector { @@ -162,7 +162,7 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { } @MainActor - func collectMetadata() async -> VPNMetadata { + func collectVPNMetadata() async -> VPNMetadata { let appInfoMetadata = collectAppInfoMetadata() let deviceInfoMetadata = collectDeviceInfoMetadata() let networkInfoMetadata = await collectNetworkInformation() @@ -216,17 +216,17 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { } private func getMachineArchitecture() -> String { - #if arch(arm) - return "arm" - #elseif arch(arm64) - return "arm64" - #elseif arch(i386) - return "i386" - #elseif arch(x86_64) - return "x86_64" - #else - return "unknown" - #endif +#if arch(arm) + return "arm" +#elseif arch(arm64) + return "arm64" +#elseif arch(i386) + return "i386" +#elseif arch(x86_64) + return "x86_64" +#else + return "unknown" +#endif } func collectNetworkInformation() async -> VPNMetadata.NetworkInfo { @@ -329,3 +329,13 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { } } + +// MARK: - Unified feedback form support + +extension VPNMetadata: UnifiedFeedbackMetadata {} + +extension DefaultVPNMetadataCollector: UnifiedMetadataCollector { + func collectMetadata() async -> VPNMetadata { + await collectVPNMetadata() + } +} diff --git a/DuckDuckGo/Windows/View/WindowControllersManager.swift b/DuckDuckGo/Windows/View/WindowControllersManager.swift index 3cbb3630d8..4e9f8548b6 100644 --- a/DuckDuckGo/Windows/View/WindowControllersManager.swift +++ b/DuckDuckGo/Windows/View/WindowControllersManager.swift @@ -19,6 +19,7 @@ import Cocoa import Combine import Common +import BrowserServicesKit @MainActor protocol WindowControllersManagerProtocol { @@ -36,14 +37,18 @@ protocol WindowControllersManagerProtocol { @MainActor final class WindowControllersManager: WindowControllersManagerProtocol { - static let shared = WindowControllersManager(pinnedTabsManager: Application.appDelegate.pinnedTabsManager) + static let shared = WindowControllersManager(pinnedTabsManager: Application.appDelegate.pinnedTabsManager, + subscriptionFeatureAvailability: DefaultSubscriptionFeatureAvailability() + ) var activeViewController: MainViewController? { lastKeyMainWindowController?.mainViewController } - init(pinnedTabsManager: PinnedTabsManager) { + init(pinnedTabsManager: PinnedTabsManager, + subscriptionFeatureAvailability: SubscriptionFeatureAvailability) { self.pinnedTabsManager = pinnedTabsManager + self.subscriptionFeatureAvailability = subscriptionFeatureAvailability } /** @@ -52,6 +57,7 @@ final class WindowControllersManager: WindowControllersManagerProtocol { @Published private(set) var isInInitialState: Bool = true @Published private(set) var mainWindowControllers = [MainWindowController]() private(set) var pinnedTabsManager: PinnedTabsManager + private let subscriptionFeatureAvailability: SubscriptionFeatureAvailability weak var lastKeyMainWindowController: MainWindowController? { didSet { @@ -226,8 +232,14 @@ extension WindowControllersManager { windowController.mainViewController.navigationBarViewController.showNetworkProtectionStatus() } - func showShareFeedbackModal() { - let feedbackFormViewController = VPNFeedbackFormViewController() + func showShareFeedbackModal(source: UnifiedFeedbackSource = .default) { + let feedbackFormViewController: NSViewController = { + if subscriptionFeatureAvailability.usesUnifiedFeedbackForm { + return UnifiedFeedbackFormViewController(source: source) + } else { + return VPNFeedbackFormViewController() + } + }() let feedbackFormWindowController = feedbackFormViewController.wrappedInWindowController() guard let feedbackFormWindow = feedbackFormWindowController.window else { diff --git a/DuckDuckGoDBPBackgroundAgent/UserText.swift b/DuckDuckGoDBPBackgroundAgent/UserText.swift index 608cab9e14..05eb833e12 100644 --- a/DuckDuckGoDBPBackgroundAgent/UserText.swift +++ b/DuckDuckGoDBPBackgroundAgent/UserText.swift @@ -21,6 +21,6 @@ import Foundation final class UserText { // MARK: - Status Menu - static let networkProtectionStatusMenuShareFeedback = NSLocalizedString("network.protection.status.menu.share.feedback", value: "Share VPN Feedback…", comment: "The status menu 'Share VPN Feedback' menu item") + static let networkProtectionStatusMenuSendFeedback = NSLocalizedString("network.protection.status.menu.send.feedback", value: "Send Feedback…", comment: "The status menu 'Send Feedback' menu item") static let networkProtectionStatusMenuOpenDuckDuckGo = NSLocalizedString("network.protection.status.menu.open.duckduckgo", value: "Open DuckDuckGo…", comment: "The status menu 'Open DuckDuckGo' menu item") } diff --git a/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift b/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift index b205ba17d8..1951ee6650 100644 --- a/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift +++ b/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift @@ -331,7 +331,7 @@ final class DuckDuckGoVPNAppDelegate: NSObject, NSApplicationDelegate { StatusBarMenu.MenuItem(name: UserText.networkProtectionStatusMenuFAQ, action: { [weak self] in try? await self?.appLauncher.launchApp(withCommand: VPNAppLaunchCommand.showFAQ) }), - StatusBarMenu.MenuItem(name: UserText.networkProtectionStatusMenuShareFeedback, action: { [weak self] in + StatusBarMenu.MenuItem(name: UserText.networkProtectionStatusMenuSendFeedback, action: { [weak self] in try? await self?.appLauncher.launchApp(withCommand: VPNAppLaunchCommand.shareFeedback) }), StatusBarMenu.MenuItem(name: UserText.networkProtectionStatusMenuOpenDuckDuckGo, action: { [weak self] in diff --git a/DuckDuckGoVPN/UserText.swift b/DuckDuckGoVPN/UserText.swift index e7fbe48d86..0e7d32e691 100644 --- a/DuckDuckGoVPN/UserText.swift +++ b/DuckDuckGoVPN/UserText.swift @@ -25,4 +25,5 @@ final class UserText { static let networkProtectionStatusMenuFAQ = NSLocalizedString("network.protection.status.menu.faq", value: "FAQs and Support…", comment: "The status menu 'FAQ' menu item") static let networkProtectionStatusMenuOpenDuckDuckGo = NSLocalizedString("network.protection.status.menu.vpn.open-duckduckgo", value: "Open DuckDuckGo…", comment: "The status menu 'Open DuckDuckGo' menu item") static let networkProtectionStatusMenuShareFeedback = NSLocalizedString("network.protection.status.menu.share.feedback", value: "Share VPN Feedback…", comment: "The status menu 'Share VPN Feedback' menu item") + static let networkProtectionStatusMenuSendFeedback = NSLocalizedString("network.protection.status.menu.send.feedback", value: "Send Feedback…", comment: "The status menu 'Send Feedback' menu item") } diff --git a/LocalPackages/DataBrokerProtection/Package.swift b/LocalPackages/DataBrokerProtection/Package.swift index 871782e9ca..c95a1baf87 100644 --- a/LocalPackages/DataBrokerProtection/Package.swift +++ b/LocalPackages/DataBrokerProtection/Package.swift @@ -29,7 +29,7 @@ let package = Package( targets: ["DataBrokerProtection"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "186.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "186.1.0"), .package(path: "../SwiftUIExtensions"), .package(path: "../XPCHelper"), ], diff --git a/LocalPackages/NetworkProtectionMac/Package.swift b/LocalPackages/NetworkProtectionMac/Package.swift index 2c50733ad9..a77a119908 100644 --- a/LocalPackages/NetworkProtectionMac/Package.swift +++ b/LocalPackages/NetworkProtectionMac/Package.swift @@ -32,7 +32,7 @@ let package = Package( .library(name: "VPNAppLauncher", targets: ["VPNAppLauncher"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "186.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "186.1.0"), .package(url: "https://github.com/airbnb/lottie-spm", exact: "4.4.3"), .package(path: "../AppLauncher"), .package(path: "../UDSHelper"), diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Extensions/UserText+NetworkProtectionUI.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Extensions/UserText+NetworkProtectionUI.swift index e418d7ce5a..e86879002c 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Extensions/UserText+NetworkProtectionUI.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Extensions/UserText+NetworkProtectionUI.swift @@ -34,7 +34,7 @@ final class UserText { static let vpnLocationSelected = NSLocalizedString("network.protection.vpn.location.selected", value: "Selected Location", comment: "Description of the location type in the VPN status view") static let vpnDnsServer = NSLocalizedString("network.protection.vpn.dns-server", value: "DNS Server", comment: "Title for the DNS server section in the VPN status view") static let vpnDataVolume = NSLocalizedString("network.protection.vpn.data-volume", value: "Data Volume", comment: "Title for the data volume section in the VPN status view") - static let vpnShareFeedback = NSLocalizedString("network.protection.vpn.share-feedback", value: "Share VPN Feedback…", comment: "Action button title for the Share VPN feedback option") + static let vpnSendFeedback = NSLocalizedString("network.protection.vpn.send-feedback", value: "Send Feedback…", comment: "Action button title for the Send feedback option") static let vpnOperationNotPermittedMessage = NSLocalizedString("network.protection.vpn.failure.operation-not-permitted", value: "Unable to connect due to an unexpected error. Restarting your Mac can usually fix the issue.", comment: "Error message for the Operation not permitted error") static let vpnLoginItemVersionMismatchedMessage = NSLocalizedString("network.protection.vpn.failure.login-item-version-mismatched", value: "Unable to connect due to versioning conflict. If you have multiple versions of the browser installed, remove all but the most recent version of DuckDuckGo and restart your Mac.", comment: "Error message for the Login item version mismatched error") static let vpnRegisteredServerFetchingFailedMessage = NSLocalizedString("network.protection.vpn.failure.registered-server-fetching-failed", value: "Unable to connect. Double check your internet connection. Make sure other software or services aren't blocking DuckDuckGo VPN servers.", comment: "Error message for the Failed to fetch registered server error") diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift index 835078e15b..8385f23f26 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift @@ -345,7 +345,7 @@ extension NetworkProtectionStatusView { var warningViewModel: WarningView.Model? { if let warningMessage = warningMessage(for: knownFailure) { return WarningView.Model(message: warningMessage, - actionTitle: UserText.vpnShareFeedback, + actionTitle: UserText.vpnSendFeedback, action: openFeedbackForm) } diff --git a/LocalPackages/SubscriptionUI/Package.swift b/LocalPackages/SubscriptionUI/Package.swift index 28af28412f..e488e81990 100644 --- a/LocalPackages/SubscriptionUI/Package.swift +++ b/LocalPackages/SubscriptionUI/Package.swift @@ -12,13 +12,14 @@ let package = Package( targets: ["SubscriptionUI"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "186.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "186.1.0"), .package(path: "../SwiftUIExtensions") ], targets: [ .target( name: "SubscriptionUI", dependencies: [ + .product(name: "BrowserServicesKit", package: "BrowserServicesKit"), .product(name: "Subscription", package: "BrowserServicesKit"), .product(name: "SwiftUIExtensions", package: "SwiftUIExtensions"), .product(name: "PreferencesViews", package: "SwiftUIExtensions"), diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift index bfacdc587e..53dcbb131e 100644 --- a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift @@ -57,6 +57,7 @@ public final class PreferencesSubscriptionModel: ObservableObject { case openVPN, openDB, openITR, + openFeedback, iHaveASubscriptionClick, activateAddEmailClick, postSubscriptionAddEmailClick, @@ -282,6 +283,11 @@ public final class PreferencesSubscriptionModel: ObservableObject { openURLHandler(subscriptionManager.url(for: .faq)) } + @MainActor + func openUnifiedFeedbackForm() { + userEventHandler(.openFeedback) + } + @MainActor func refreshSubscriptionPendingState() { if subscriptionManager.currentEnvironment.purchasePlatform == .appStore { diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift index 57a37fe5c4..283f5e7f7e 100644 --- a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift @@ -19,6 +19,7 @@ import PreferencesViews import SwiftUI import SwiftUIExtensions +import BrowserServicesKit public struct PreferencesSubscriptionView: View { @@ -30,8 +31,12 @@ public struct PreferencesSubscriptionView: View { @State private var manageSubscriptionSheet: ManageSubscriptionSheet? - public init(model: PreferencesSubscriptionModel) { + private let subscriptionFeatureAvailability: SubscriptionFeatureAvailability + + public init(model: PreferencesSubscriptionModel, + subscriptionFeatureAvailability: SubscriptionFeatureAvailability) { self.model = model + self.subscriptionFeatureAvailability = subscriptionFeatureAvailability } public var body: some View { @@ -96,6 +101,11 @@ public struct PreferencesSubscriptionView: View { // Help section helpSection + + // Feedback section + if subscriptionFeatureAvailability.usesUnifiedFeedbackForm, state == .subscriptionActive { + feedbackSection + } } .onAppear(perform: { if model.isUserAuthenticated { @@ -277,6 +287,17 @@ public struct PreferencesSubscriptionView: View { } } + @ViewBuilder + private var feedbackSection: some View { + PreferencePaneSection { + TextMenuItemHeader(UserText.preferencesSubscriptionFeedbackTitle, bottomPadding: 0) + HStack(alignment: .top, spacing: 6) { + TextMenuItemCaption(UserText.preferencesSubscriptionFeedbackCaption) + Button(UserText.preferencesSubscriptionFeedbackButton) { model.openUnifiedFeedbackForm() } + } + } + } + @ViewBuilder private var emailView: some View { VStack { diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/UserText.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/UserText.swift index 388c2c0131..0a22093283 100644 --- a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/UserText.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/UserText.swift @@ -50,6 +50,9 @@ enum UserText { static let preferencesSubscriptionFooterTitle = NSLocalizedString("subscription.preferences.subscription.footer.title", value: "Need help with Privacy Pro?", comment: "Title for the subscription preferences pane footer") static let preferencesSubscriptionFooterCaption = NSLocalizedString("subscription.preferences.subscription.footer.caption", value: "Get answers to frequently asked questions or contact Privacy Pro support from our help pages.", comment: "Caption for the subscription preferences pane footer") static let viewFaqsButton = NSLocalizedString("subscription.preferences.view.faqs.button", value: "FAQs and Support", comment: "Button to open page for FAQs") + static let preferencesSubscriptionFeedbackTitle = NSLocalizedString("subscription.preferences.feedback.title", value: "Send Feedback", comment: "Title for the subscription feedback section") + static let preferencesSubscriptionFeedbackCaption = NSLocalizedString("subscription.preferences.feedback.caption", value: "Help improve Privacy Pro. Your feedback matters to us. Feel free to report any issues or provide general feedback.", comment: "Caption for the subscription feedback section") + static let preferencesSubscriptionFeedbackButton = NSLocalizedString("subscription.preferences.feedback.button", value: "Send Feedback", comment: "Title for the subscription feedback button") static func preferencesSubscriptionActiveRenewCaption(period: String, formattedDate: String) -> String { let localized = NSLocalizedString("subscription.preferences.subscription.active.renew.caption", value: "Your %@ Privacy Pro subscription renews on %@.", comment: "Caption for the subscription preferences pane when the subscription is active and will renew. First parameter is renewal period (monthly/yearly). Second parameter is date.") diff --git a/UnitTests/DBP/Tests/DataBrokerProtectionFeatureGatekeeperTests.swift b/UnitTests/DBP/Tests/DataBrokerProtectionFeatureGatekeeperTests.swift index f85d03a145..b64e193a4b 100644 --- a/UnitTests/DBP/Tests/DataBrokerProtectionFeatureGatekeeperTests.swift +++ b/UnitTests/DBP/Tests/DataBrokerProtectionFeatureGatekeeperTests.swift @@ -123,12 +123,15 @@ private class MockFeatureDisabler: DataBrokerProtectionFeatureDisabling { private class MockFeatureAvailability: SubscriptionFeatureAvailability { var mockFeatureAvailable: Bool = false var mockSubscriptionPurchaseAllowed: Bool = false + var mockUsesUnifiedFeedbackForm: Bool = false var isFeatureAvailable: Bool { mockFeatureAvailable } var isSubscriptionPurchaseAllowed: Bool { mockSubscriptionPurchaseAllowed } + var usesUnifiedFeedbackForm: Bool { mockUsesUnifiedFeedbackForm } func reset() { mockFeatureAvailable = false mockSubscriptionPurchaseAllowed = false + mockUsesUnifiedFeedbackForm = false } } diff --git a/UnitTests/Menus/MoreOptionsMenuTests.swift b/UnitTests/Menus/MoreOptionsMenuTests.swift index 97b37a84c0..6d95c92e88 100644 --- a/UnitTests/Menus/MoreOptionsMenuTests.swift +++ b/UnitTests/Menus/MoreOptionsMenuTests.swift @@ -81,7 +81,8 @@ final class MoreOptionsMenuTests: XCTestCase { passwordManagerCoordinator: passwordManagerCoordinator, vpnFeatureGatekeeper: networkProtectionVisibilityMock, subscriptionFeatureAvailability: SubscriptionFeatureAvailabilityMock(isFeatureAvailable: true, - isSubscriptionPurchaseAllowed: true), + isSubscriptionPurchaseAllowed: true, + usesUnifiedFeedbackForm: false), sharingMenu: NSMenu(), internalUserDecider: internalUserDecider, subscriptionManager: subscriptionManager) diff --git a/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift b/UnitTests/NetworkProtection/VPNFeedbackFormViewModelTests.swift similarity index 99% rename from UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift rename to UnitTests/NetworkProtection/VPNFeedbackFormViewModelTests.swift index a12d3a8fa1..9494befe17 100644 --- a/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift +++ b/UnitTests/NetworkProtection/VPNFeedbackFormViewModelTests.swift @@ -80,7 +80,7 @@ private class MockVPNMetadataCollector: VPNMetadataCollector { var collectedMetadata: Bool = false - func collectMetadata() async -> VPNMetadata { + func collectVPNMetadata() async -> VPNMetadata { self.collectedMetadata = true let appInfo = VPNMetadata.AppInfo( diff --git a/UnitTests/Subscription/Mocks/SubscriptionFeatureAvailabilityMock.swift b/UnitTests/Subscription/Mocks/SubscriptionFeatureAvailabilityMock.swift index d6153695f7..aa5c9a0b6f 100644 --- a/UnitTests/Subscription/Mocks/SubscriptionFeatureAvailabilityMock.swift +++ b/UnitTests/Subscription/Mocks/SubscriptionFeatureAvailabilityMock.swift @@ -23,9 +23,11 @@ import BrowserServicesKit public struct SubscriptionFeatureAvailabilityMock: SubscriptionFeatureAvailability { public var isFeatureAvailable: Bool public var isSubscriptionPurchaseAllowed: Bool + public var usesUnifiedFeedbackForm: Bool - public init(isFeatureAvailable: Bool, isSubscriptionPurchaseAllowed: Bool) { + public init(isFeatureAvailable: Bool, isSubscriptionPurchaseAllowed: Bool, usesUnifiedFeedbackForm: Bool) { self.isFeatureAvailable = isFeatureAvailable self.isSubscriptionPurchaseAllowed = isSubscriptionPurchaseAllowed + self.usesUnifiedFeedbackForm = usesUnifiedFeedbackForm } } diff --git a/UnitTests/UnifiedFeedbackForm/UnifiedFeedbackFormViewModelTests.swift b/UnitTests/UnifiedFeedbackForm/UnifiedFeedbackFormViewModelTests.swift new file mode 100644 index 0000000000..2e7dae8803 --- /dev/null +++ b/UnitTests/UnifiedFeedbackForm/UnifiedFeedbackFormViewModelTests.swift @@ -0,0 +1,195 @@ +// +// UnifiedFeedbackFormViewModelTests.swift +// +// Copyright © 2023 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 XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class UnifiedFeedbackFormViewModelTests: XCTestCase { + + func testWhenCreatingViewModel_ThenInitialStateIsFeedbackPending() throws { + let collector = MockVPNMetadataCollector() + let sender = MockVPNFeedbackSender() + let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: collector, feedbackSender: sender) + + XCTAssertEqual(viewModel.viewState, .feedbackPending) + } + + func testWhenSendingFeedbackSucceeds_ThenFeedbackIsSent() async throws { + let collector = MockVPNMetadataCollector() + let sender = MockVPNFeedbackSender() + let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: collector, feedbackSender: sender) + viewModel.selectedReportType = UnifiedFeedbackReportType.reportIssue.rawValue + let text = "Some feedback report text" + viewModel.feedbackFormText = text + + XCTAssertFalse(sender.sentMetadata) + await viewModel.process(action: .submit) + XCTAssertTrue(sender.sentMetadata) + XCTAssertEqual(sender.receivedData!.4, text) + } + + func testWhenSendingFeedbackFails_ThenFeedbackIsNotSent() async throws { + let collector = MockVPNMetadataCollector() + let sender = MockVPNFeedbackSender() + let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: collector, feedbackSender: sender) + viewModel.selectedReportType = UnifiedFeedbackReportType.reportIssue.rawValue + let text = "Some feedback report text" + viewModel.feedbackFormText = text + sender.throwErrorWhenSending = true + + XCTAssertFalse(sender.sentMetadata) + await viewModel.process(action: .submit) + XCTAssertFalse(sender.sentMetadata) + XCTAssertEqual(viewModel.viewState, .feedbackSendingFailed) + } + + func testWhenCancelActionIsReceived_ThenViewModelSendsCancelActionToDelegate() async throws { + let collector = MockVPNMetadataCollector() + let sender = MockVPNFeedbackSender() + let delegate = MockVPNFeedbackFormViewModelDelegate() + let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: collector, feedbackSender: sender) + viewModel.delegate = delegate + + XCTAssertFalse(delegate.receivedDismissedViewCallback) + await viewModel.process(action: .cancel) + XCTAssertTrue(delegate.receivedDismissedViewCallback) + } +} + +// MARK: - Mocks + +private class MockVPNMetadataCollector: UnifiedMetadataCollector { + var collectedMetadata = false + + func collectMetadata() async -> VPNMetadata { + self.collectedMetadata = true + + let appInfo = VPNMetadata.AppInfo( + appVersion: "1.2.3", + lastAgentVersionRun: "1.2.3", + lastExtensionVersionRun: "1.2.3", + isInternalUser: false, + isInApplicationsDirectory: true + ) + + let deviceInfo = VPNMetadata.DeviceInfo( + osVersion: "14.0.0", + buildFlavor: "dmg", + lowPowerModeEnabled: false, + cpuArchitecture: "arm64" + ) + + let networkInfo = VPNMetadata.NetworkInfo(currentPath: "path") + + let vpnState = VPNMetadata.VPNState( + onboardingState: "onboarded", + connectionState: "connected", + lastStartErrorDescription: "none", + lastTunnelErrorDescription: "none", + lastKnownFailureDescription: "none", + connectedServer: "Paoli, PA", + connectedServerIP: "123.123.123.123" + ) + + let vpnSettingsState = VPNMetadata.VPNSettingsState( + connectOnLoginEnabled: true, + includeAllNetworksEnabled: true, + enforceRoutesEnabled: true, + excludeLocalNetworksEnabled: true, + notifyStatusChangesEnabled: true, + showInMenuBarEnabled: true, + selectedServer: "server", + selectedEnvironment: "production", + customDNS: false + ) + + let loginItemState = VPNMetadata.LoginItemState( + vpnMenuState: "enabled", + vpnMenuIsRunning: true, + notificationsAgentState: "enabled", + notificationsAgentIsRunning: true + ) + + let privacyProInfo = VPNMetadata.PrivacyProInfo( + hasPrivacyProAccount: true, + hasVPNEntitlement: true + ) + + return VPNMetadata( + appInfo: appInfo, + deviceInfo: deviceInfo, + networkInfo: networkInfo, + vpnState: vpnState, + vpnSettingsState: vpnSettingsState, + loginItemState: loginItemState, + privacyProInfo: privacyProInfo + ) + } + +} + +private class MockVPNFeedbackSender: UnifiedFeedbackSender { + var throwErrorWhenSending: Bool = false + var sentMetadata: Bool = false + + var receivedData: (VPNMetadata?, UnifiedFeedbackSource, String?, String?, String?)? + + enum SomeError: Error { + case error + } + + func sendFeatureRequestPixel(description: String, source: UnifiedFeedbackSource) async throws { + if throwErrorWhenSending { + throw SomeError.error + } + + self.sentMetadata = true + self.receivedData = (nil, source, nil, nil, description) + } + + func sendGeneralFeedbackPixel(description: String, source: UnifiedFeedbackSource) async throws { + if throwErrorWhenSending { + throw SomeError.error + } + + self.sentMetadata = true + self.receivedData = (nil, source, nil, nil, description) + } + + func sendReportIssuePixel(source: UnifiedFeedbackSource, category: String, subcategory: String, description: String, metadata: T?) async throws { + if throwErrorWhenSending { + throw SomeError.error + } + + self.sentMetadata = true + self.receivedData = (metadata as? VPNMetadata, source, category, subcategory, description) + } + + func sendFormShowPixel() {} + func sendSubmitScreenShowPixel(source: UnifiedFeedbackSource, reportType: String, category: String, subcategory: String) {} + func sendSubmitScreenFAQClickPixel(source: UnifiedFeedbackSource, reportType: String, category: String, subcategory: String) {} +} + +private class MockVPNFeedbackFormViewModelDelegate: UnifiedFeedbackFormViewModelDelegate { + var receivedDismissedViewCallback: Bool = false + + func feedbackViewModelDismissedView(_ viewModel: UnifiedFeedbackFormViewModel) { + receivedDismissedViewCallback = true + } + +}