From 7eef2c50dbc9964529e55734c64a83f62142ac3f Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Tue, 28 Nov 2023 14:33:11 -0800 Subject: [PATCH 01/34] Add a prototype feedback form implementation. --- DuckDuckGo.xcodeproj/project.pbxproj | 32 +++++++ .../NetworkProtectionDebugMenu.swift | 26 ++++++ .../FeedbackFormModel.swift | 86 +++++++++++++++++++ .../FeedbackFormView.swift | 64 ++++++++++++++ .../FeedbackFormViewController.swift | 71 +++++++++++++++ 5 files changed, 279 insertions(+) create mode 100644 DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormModel.swift create mode 100644 DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift create mode 100644 DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index d5b9ce06ae..6ada646a54 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1116,6 +1116,15 @@ 4B41EDA82B1543C9001EEDF4 /* PreferencesVPNView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDA62B1543C9001EEDF4 /* PreferencesVPNView.swift */; }; 4B41EDA92B1543C9001EEDF4 /* PreferencesVPNView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDA62B1543C9001EEDF4 /* PreferencesVPNView.swift */; }; 4B41EDAB2B1544B2001EEDF4 /* LoginItems in Frameworks */ = {isa = PBXBuildFile; productRef = 4B41EDAA2B1544B2001EEDF4 /* LoginItems */; }; + 4B41EDAE2B168AFF001EEDF4 /* FeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* FeedbackFormViewController.swift */; }; + 4B41EDAF2B168AFF001EEDF4 /* FeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* FeedbackFormViewController.swift */; }; + 4B41EDB12B168B1E001EEDF4 /* FeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */; }; + 4B41EDB22B168B1E001EEDF4 /* FeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */; }; + 4B41EDB42B168C55001EEDF4 /* FeedbackFormModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* FeedbackFormModel.swift */; }; + 4B41EDB52B168C55001EEDF4 /* FeedbackFormModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* FeedbackFormModel.swift */; }; + 4B41EDB62B169883001EEDF4 /* FeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* FeedbackFormViewController.swift */; }; + 4B41EDB72B169887001EEDF4 /* FeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */; }; + 4B41EDB82B169889001EEDF4 /* FeedbackFormModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* FeedbackFormModel.swift */; }; 4B434690285ED7A100177407 /* BookmarksBarViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B43468F285ED7A100177407 /* BookmarksBarViewModelTests.swift */; }; 4B43469528655D1400177407 /* FirefoxDataImporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B43469428655D1400177407 /* FirefoxDataImporterTests.swift */; }; 4B4BEC3D2A11B56B001D9AC5 /* DuckDuckGoNotificationsAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4BEC382A11B509001D9AC5 /* DuckDuckGoNotificationsAppDelegate.swift */; }; @@ -3329,6 +3338,9 @@ 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 /* FeedbackFormViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackFormViewController.swift; sourceTree = ""; }; + 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackFormView.swift; sourceTree = ""; }; + 4B41EDB32B168C55001EEDF4 /* FeedbackFormModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackFormModel.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 = ""; }; 4B4BEC182A11B3EA001D9AC5 /* DuckDuckGoNotifications.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DuckDuckGoNotifications.xcconfig; sourceTree = ""; }; @@ -5022,6 +5034,16 @@ path = DeviceAuthentication; sourceTree = ""; }; + 4B41EDAC2B168A66001EEDF4 /* NetworkProtectionFeedbackForm */ = { + isa = PBXGroup; + children = ( + 4B41EDAD2B168AFF001EEDF4 /* FeedbackFormViewController.swift */, + 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */, + 4B41EDB32B168C55001EEDF4 /* FeedbackFormModel.swift */, + ); + path = NetworkProtectionFeedbackForm; + sourceTree = ""; + }; 4B43468D285ED6BD00177407 /* BookmarksBar */ = { isa = PBXGroup; children = ( @@ -6383,6 +6405,7 @@ AA97BF4425135CB60014931A /* Menus */, 85378D9A274E618C007C5CBF /* MessageViews */, AA86491524D83384001BABEE /* NavigationBar */, + 4B41EDAC2B168A66001EEDF4 /* NetworkProtectionFeedbackForm */, 4B4D60542A0B29FA00BCD287 /* NetworkProtection */, 85B7184727677A7D00B4277F /* Onboarding */, 1D074B252909A371006E4AC3 /* PasswordManager */, @@ -9444,6 +9467,7 @@ 3706FBA4293F65D500E42796 /* ContentOverlayPopover.swift in Sources */, 3706FBA5293F65D500E42796 /* TabShadowView.swift in Sources */, 3706FBA7293F65D500E42796 /* EncryptedValueTransformer.swift in Sources */, + 4B41EDAF2B168AFF001EEDF4 /* FeedbackFormViewController.swift in Sources */, 3706FBA8293F65D500E42796 /* PasteboardBookmark.swift in Sources */, 3706FBA9293F65D500E42796 /* PinnedTabsManager.swift in Sources */, B66260E829ACD0C900E9E3EE /* DuckPlayerTabExtension.swift in Sources */, @@ -9507,6 +9531,7 @@ 3706FBDA293F65D500E42796 /* PermissionManager.swift in Sources */, 3706FBDB293F65D500E42796 /* DefaultBrowserPreferences.swift in Sources */, 3706FBDC293F65D500E42796 /* Permissions.xcdatamodeld in Sources */, + 4B41EDB52B168C55001EEDF4 /* FeedbackFormModel.swift in Sources */, 3706FBDD293F65D500E42796 /* PaddedImageButton.swift in Sources */, 37CEFCAA2A6737A2001EF741 /* CredentialsCleanupErrorHandling.swift in Sources */, 4BCF15F02ABBDC010083F6DF /* NetworkProtectionRemoteMessagingStorage.swift in Sources */, @@ -9682,6 +9707,7 @@ 3706FC69293F65D500E42796 /* UserScripts.swift in Sources */, 3706FC6A293F65D500E42796 /* NSWorkspaceExtension.swift in Sources */, B6C0BB6829AEFF8100AE8E3C /* BookmarkExtension.swift in Sources */, + 4B41EDB22B168B1E001EEDF4 /* FeedbackFormView.swift in Sources */, 3706FC6C293F65D500E42796 /* BookmarkViewModel.swift in Sources */, 3706FC6D293F65D500E42796 /* DaxSpeech.swift in Sources */, 3706FC6E293F65D500E42796 /* DuckPlayerSchemeHandler.swift in Sources */, @@ -10202,6 +10228,7 @@ 4B9579652AC7AE700062CA31 /* SecureVaultSorting.swift in Sources */, 4B9579662AC7AE700062CA31 /* PreferencesSidebarModel.swift in Sources */, 4B9579672AC7AE700062CA31 /* DuckPlayerURLExtension.swift in Sources */, + 4B41EDB72B169887001EEDF4 /* FeedbackFormView.swift in Sources */, 4B9579682AC7AE700062CA31 /* BWEncryptionOutput.m in Sources */, 4B9579692AC7AE700062CA31 /* PermissionState.swift in Sources */, 4B95796A2AC7AE700062CA31 /* FeedbackPresenter.swift in Sources */, @@ -10246,6 +10273,7 @@ 4B9579912AC7AE700062CA31 /* NSNotificationName+PasswordManager.swift in Sources */, 4B9579922AC7AE700062CA31 /* RulesCompilationMonitor.swift in Sources */, 4B9579932AC7AE700062CA31 /* FBProtectionTabExtension.swift in Sources */, + 4B41EDB82B169889001EEDF4 /* FeedbackFormModel.swift in Sources */, 4B9579942AC7AE700062CA31 /* CrashReportReader.swift in Sources */, 4B9579952AC7AE700062CA31 /* DataTaskProviding.swift in Sources */, 4B9579962AC7AE700062CA31 /* FeatureFlag.swift in Sources */, @@ -10285,6 +10313,7 @@ 4B9579B62AC7AE700062CA31 /* AtbParser.swift in Sources */, 4B9579B72AC7AE700062CA31 /* PreferencesDuckPlayerView.swift in Sources */, 4B9579B82AC7AE700062CA31 /* AddFolderModalViewController.swift in Sources */, + 4B41EDB62B169883001EEDF4 /* FeedbackFormViewController.swift in Sources */, 4B9579B92AC7AE700062CA31 /* BookmarkSidebarTreeController.swift in Sources */, 4B9579BA2AC7AE700062CA31 /* HomePageFavoritesModel.swift in Sources */, 4B9579BB2AC7AE700062CA31 /* SequenceExtensions.swift in Sources */, @@ -10967,6 +10996,7 @@ 4B9292D72667124000AD2C21 /* NSPopUpButtonExtension.swift in Sources */, 85D33F1225C82EB3002B91A6 /* ConfigurationManager.swift in Sources */, 31F28C4F28C8EEC500119F70 /* YoutubePlayerUserScript.swift in Sources */, + 4B41EDAE2B168AFF001EEDF4 /* FeedbackFormViewController.swift in Sources */, B6A9E48426146AAB0067D1B9 /* PixelParameters.swift in Sources */, AA5FA697275F90C400DCE9C9 /* FaviconImageCache.swift in Sources */, 1430DFF524D0580F00B8978C /* TabBarViewController.swift in Sources */, @@ -11299,6 +11329,7 @@ 4B9292D52667123700AD2C21 /* BookmarkManagementDetailViewController.swift in Sources */, 4B723E1026B0006700E14D75 /* CSVImporter.swift in Sources */, 37A4CEBA282E992F00D75B89 /* StartupPreferences.swift in Sources */, + 4B41EDB12B168B1E001EEDF4 /* FeedbackFormView.swift in Sources */, 7BFE95542A9DF2930081ABE9 /* UserDefaults+NetworkProtectionWaitlist.swift in Sources */, AA4BBA3B25C58FA200C4FB0F /* MainMenu.swift in Sources */, 4B8AC93326B3B06300879451 /* EdgeDataImporter.swift in Sources */, @@ -11390,6 +11421,7 @@ 4B9292A326670D2A00AD2C21 /* BookmarkManagedObject.swift in Sources */, 4B723E1326B0007A00E14D75 /* CSVLoginExporter.swift in Sources */, 85C48CCC278D808F00D3263E /* NSAttributedStringExtension.swift in Sources */, + 4B41EDB42B168C55001EEDF4 /* FeedbackFormModel.swift in Sources */, AA7EB6E527E7D6DC00036718 /* AnimationView.swift in Sources */, 8562599A269CA0A600EE44BC /* NSRectExtension.swift in Sources */, 4B9DB0472A983B24000927DB /* WaitlistRootView.swift in Sources */, diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift index cc6b0871ed..05080d401f 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift @@ -94,6 +94,9 @@ final class NetworkProtectionDebugMenu: NSMenu { NSMenuItem(title: "Send Test Notification", action: #selector(NetworkProtectionDebugMenu.sendTestNotification)) .targetting(self) + NSMenuItem(title: "Open Native Feedback Form", action: #selector(NetworkProtectionDebugMenu.openNativeFeedbackForm)) + .targetting(self) + NSMenuItem(title: "Onboarding") .submenu(NetworkProtectionOnboardingMenu()) @@ -233,6 +236,29 @@ final class NetworkProtectionDebugMenu: NSMenu { } } + @objc func openNativeFeedbackForm(_ sender: Any?) { + print("TODO") + let feedbackFormViewController = FeedbackFormViewController(formOptions: [ + FeedbackFormViewModel.FeedbackFormOption(id: "first", title: "Testing", components: []), + FeedbackFormViewModel.FeedbackFormOption(id: "second", title: "Testing 2", components: []), + FeedbackFormViewModel.FeedbackFormOption(id: "third", title: "Testing 3", components: []) + ]) + + let feedbackFormWindowController = feedbackFormViewController.wrappedInWindowController() + + guard let feedbackFormWindow = feedbackFormWindowController.window, + let parentWindowController = WindowControllersManager.shared.lastKeyMainWindowController + else { + assertionFailure("Failed to present native VPN feedback form") + return + } + + parentWindowController.window?.beginSheet(feedbackFormWindow) { [weak self] _ in + + } + + } + /// Sets the selected server. /// @objc func setSelectedServer(_ menuItem: NSMenuItem) { diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormModel.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormModel.swift new file mode 100644 index 0000000000..53b5795839 --- /dev/null +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormModel.swift @@ -0,0 +1,86 @@ +// +// FeedbackFormModel.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 + +final class FeedbackFormViewModel: ObservableObject { + + struct FeedbackFormOption: Identifiable, Equatable { + let id: String + let title: String + let components: [any FeedbackFormComponent] + + static func == (lhs: FeedbackFormViewModel.FeedbackFormOption, rhs: FeedbackFormViewModel.FeedbackFormOption) -> Bool { + lhs.id == rhs.id + } + } + + enum ViewAction { + case cancel + case submit + } + + let options: [FeedbackFormOption] + + @State var selectedOption: FeedbackFormOption + + init(options: [FeedbackFormOption]) { + self.options = options + + guard let firstOption = options.first else { + fatalError("FeedbackFormViewModel requires at least one option") + } + + self.selectedOption = firstOption + } + + func process(action: ViewAction) { + switch action { + case .cancel: break + case .submit: break + } + } + +} + +enum FeedbackFormComponentType { + case textField + case textView + case textBlock +} + +protocol FeedbackFormComponent: Equatable { + var componentType: FeedbackFormComponentType { get } +} + +struct FeedbackFormComponentTextField: FeedbackFormComponent { + let componentType = FeedbackFormComponentType.textField + var textFieldValue: String = "" +} + +struct FeedbackFormComponentTextView: FeedbackFormComponent { + let componentType = FeedbackFormComponentType.textView + var textViewValue: String = "" +} + +struct FeedbackFormComponentTextBlock: FeedbackFormComponent { + let componentType = FeedbackFormComponentType.textBlock + let stringValue: String +} diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift new file mode 100644 index 0000000000..5941378bfb --- /dev/null +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift @@ -0,0 +1,64 @@ +// +// FeedbackFormView.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 SwiftUI + +struct FeedbackFormView: View { + + private enum Constants { + static let headerPadding = 20.0 + static let bodyPadding = 20.0 + } + + struct ViewSize { + fileprivate(set) var headerHeight: Double = 0.0 + fileprivate(set) var viewHeight: Double = 0.0 + fileprivate(set) var spacerHeight: Double = 0.0 + fileprivate(set) var buttonsHeight: Double = 0.0 + + var totalHeight: Double { + headerHeight + 2 * Constants.headerPadding + viewHeight + 4 * Constants.bodyPadding + spacerHeight + buttonsHeight + } + } + + @EnvironmentObject var viewModel: FeedbackFormViewModel + + let sizeChanged: (CGFloat) -> Void + + @State var viewSize: ViewSize = .init() { + didSet { + sizeChanged(viewSize.totalHeight) + } + } + + @State var notifyMeAbout: String = "Direct Messages" + @State var playNotificationSounds: Bool = false + @State var profileImageSize: String = "Large" + + var body: some View { + Text("Body Goes Here") + + Picker(selection: $viewModel.selectedOption) { + ForEach(viewModel.options) { option in + Text(option.title).tag(option.title) + } + } + } + +} diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift new file mode 100644 index 0000000000..aac786889f --- /dev/null +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift @@ -0,0 +1,71 @@ +// +// FeedbackFormViewController.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 + +final class FeedbackFormViewController: NSViewController { + + private let defaultSize = CGSize(width: 550, height: 280) + private let viewModel: FeedbackFormViewModel + + private var heightConstraint: NSLayoutConstraint? + + init(formOptions: [FeedbackFormViewModel.FeedbackFormOption]) { + self.viewModel = FeedbackFormViewModel(options: formOptions) + super.init(nibName: nil, bundle: nil) + } + + 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 = FeedbackFormView { newHeight in + self.updateViewHeight(height: newHeight) + } + + 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) + ]) + } + + private func updateViewHeight(height: CGFloat) { + heightConstraint?.constant = height + } + +} From 73b56c79a21921db4622bd6e40c56ad5be2949a5 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Thu, 30 Nov 2023 19:57:32 -0800 Subject: [PATCH 02/34] Continue working on the form. --- DuckDuckGo.xcodeproj/project.pbxproj | 16 +-- .../NetworkProtectionDebugMenu.swift | 9 +- .../FeedbackFormModel.swift | 86 ------------ .../FeedbackFormView.swift | 131 ++++++++++++++++-- .../FeedbackFormViewController.swift | 22 ++- .../VPNFeedbackFormViewModel.swift | 86 ++++++++++++ 6 files changed, 233 insertions(+), 117 deletions(-) delete mode 100644 DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormModel.swift create mode 100644 DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 2dcf0b00cf..f4fe45006f 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1126,11 +1126,11 @@ 4B41EDAF2B168AFF001EEDF4 /* FeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* FeedbackFormViewController.swift */; }; 4B41EDB12B168B1E001EEDF4 /* FeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */; }; 4B41EDB22B168B1E001EEDF4 /* FeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */; }; - 4B41EDB42B168C55001EEDF4 /* FeedbackFormModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* FeedbackFormModel.swift */; }; - 4B41EDB52B168C55001EEDF4 /* FeedbackFormModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* FeedbackFormModel.swift */; }; + 4B41EDB42B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */; }; + 4B41EDB52B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */; }; 4B41EDB62B169883001EEDF4 /* FeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* FeedbackFormViewController.swift */; }; 4B41EDB72B169887001EEDF4 /* FeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */; }; - 4B41EDB82B169889001EEDF4 /* FeedbackFormModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* FeedbackFormModel.swift */; }; + 4B41EDB82B169889001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */; }; 4B434690285ED7A100177407 /* BookmarksBarViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B43468F285ED7A100177407 /* BookmarksBarViewModelTests.swift */; }; 4B43469528655D1400177407 /* FirefoxDataImporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B43469428655D1400177407 /* FirefoxDataImporterTests.swift */; }; 4B4BEC3D2A11B56B001D9AC5 /* DuckDuckGoNotificationsAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4BEC382A11B509001D9AC5 /* DuckDuckGoNotificationsAppDelegate.swift */; }; @@ -3348,7 +3348,7 @@ 4B41EDA62B1543C9001EEDF4 /* PreferencesVPNView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesVPNView.swift; sourceTree = ""; }; 4B41EDAD2B168AFF001EEDF4 /* FeedbackFormViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackFormViewController.swift; sourceTree = ""; }; 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackFormView.swift; sourceTree = ""; }; - 4B41EDB32B168C55001EEDF4 /* FeedbackFormModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackFormModel.swift; sourceTree = ""; }; + 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewModel.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 = ""; }; 4B4BEC182A11B3EA001D9AC5 /* DuckDuckGoNotifications.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DuckDuckGoNotifications.xcconfig; sourceTree = ""; }; @@ -5049,7 +5049,7 @@ children = ( 4B41EDAD2B168AFF001EEDF4 /* FeedbackFormViewController.swift */, 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */, - 4B41EDB32B168C55001EEDF4 /* FeedbackFormModel.swift */, + 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */, ); path = NetworkProtectionFeedbackForm; sourceTree = ""; @@ -9542,7 +9542,7 @@ 3706FBDA293F65D500E42796 /* PermissionManager.swift in Sources */, 3706FBDB293F65D500E42796 /* DefaultBrowserPreferences.swift in Sources */, 3706FBDC293F65D500E42796 /* Permissions.xcdatamodeld in Sources */, - 4B41EDB52B168C55001EEDF4 /* FeedbackFormModel.swift in Sources */, + 4B41EDB52B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */, 3706FBDD293F65D500E42796 /* PaddedImageButton.swift in Sources */, 37CEFCAA2A6737A2001EF741 /* CredentialsCleanupErrorHandling.swift in Sources */, 4BCF15F02ABBDC010083F6DF /* NetworkProtectionRemoteMessagingStorage.swift in Sources */, @@ -10285,7 +10285,7 @@ 4B9579912AC7AE700062CA31 /* NSNotificationName+PasswordManager.swift in Sources */, 4B9579922AC7AE700062CA31 /* RulesCompilationMonitor.swift in Sources */, 4B9579932AC7AE700062CA31 /* FBProtectionTabExtension.swift in Sources */, - 4B41EDB82B169889001EEDF4 /* FeedbackFormModel.swift in Sources */, + 4B41EDB82B169889001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */, 4B9579942AC7AE700062CA31 /* CrashReportReader.swift in Sources */, 4B9579952AC7AE700062CA31 /* DataTaskProviding.swift in Sources */, 4B9579962AC7AE700062CA31 /* FeatureFlag.swift in Sources */, @@ -11437,7 +11437,7 @@ 4B9292A326670D2A00AD2C21 /* BookmarkManagedObject.swift in Sources */, 4B723E1326B0007A00E14D75 /* CSVLoginExporter.swift in Sources */, 85C48CCC278D808F00D3263E /* NSAttributedStringExtension.swift in Sources */, - 4B41EDB42B168C55001EEDF4 /* FeedbackFormModel.swift in Sources */, + 4B41EDB42B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */, AA7EB6E527E7D6DC00036718 /* AnimationView.swift in Sources */, 8562599A269CA0A600EE44BC /* NSRectExtension.swift in Sources */, 4B9DB0472A983B24000927DB /* WaitlistRootView.swift in Sources */, diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift index 0e754abf75..1a4b44891d 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift @@ -238,12 +238,7 @@ final class NetworkProtectionDebugMenu: NSMenu { @objc func openNativeFeedbackForm(_ sender: Any?) { print("TODO") - let feedbackFormViewController = FeedbackFormViewController(formOptions: [ - FeedbackFormViewModel.FeedbackFormOption(id: "first", title: "Testing", components: []), - FeedbackFormViewModel.FeedbackFormOption(id: "second", title: "Testing 2", components: []), - FeedbackFormViewModel.FeedbackFormOption(id: "third", title: "Testing 3", components: []) - ]) - + let feedbackFormViewController = FeedbackFormViewController() let feedbackFormWindowController = feedbackFormViewController.wrappedInWindowController() guard let feedbackFormWindow = feedbackFormWindowController.window, @@ -254,7 +249,7 @@ final class NetworkProtectionDebugMenu: NSMenu { } parentWindowController.window?.beginSheet(feedbackFormWindow) { [weak self] _ in - + print("DEBUG: Form closed") } } diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormModel.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormModel.swift deleted file mode 100644 index 53b5795839..0000000000 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormModel.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// FeedbackFormModel.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 - -final class FeedbackFormViewModel: ObservableObject { - - struct FeedbackFormOption: Identifiable, Equatable { - let id: String - let title: String - let components: [any FeedbackFormComponent] - - static func == (lhs: FeedbackFormViewModel.FeedbackFormOption, rhs: FeedbackFormViewModel.FeedbackFormOption) -> Bool { - lhs.id == rhs.id - } - } - - enum ViewAction { - case cancel - case submit - } - - let options: [FeedbackFormOption] - - @State var selectedOption: FeedbackFormOption - - init(options: [FeedbackFormOption]) { - self.options = options - - guard let firstOption = options.first else { - fatalError("FeedbackFormViewModel requires at least one option") - } - - self.selectedOption = firstOption - } - - func process(action: ViewAction) { - switch action { - case .cancel: break - case .submit: break - } - } - -} - -enum FeedbackFormComponentType { - case textField - case textView - case textBlock -} - -protocol FeedbackFormComponent: Equatable { - var componentType: FeedbackFormComponentType { get } -} - -struct FeedbackFormComponentTextField: FeedbackFormComponent { - let componentType = FeedbackFormComponentType.textField - var textFieldValue: String = "" -} - -struct FeedbackFormComponentTextView: FeedbackFormComponent { - let componentType = FeedbackFormComponentType.textView - var textViewValue: String = "" -} - -struct FeedbackFormComponentTextBlock: FeedbackFormComponent { - let componentType = FeedbackFormComponentType.textBlock - let stringValue: String -} diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift index 5941378bfb..2e159a13be 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift @@ -33,31 +33,142 @@ struct FeedbackFormView: View { fileprivate(set) var buttonsHeight: Double = 0.0 var totalHeight: Double { - headerHeight + 2 * Constants.headerPadding + viewHeight + 4 * Constants.bodyPadding + spacerHeight + buttonsHeight + headerHeight + viewHeight + spacerHeight + buttonsHeight + 70 } } - @EnvironmentObject var viewModel: FeedbackFormViewModel + @EnvironmentObject var viewModel: VPNFeedbackFormViewModel let sizeChanged: (CGFloat) -> Void @State var viewSize: ViewSize = .init() { didSet { + print("DEBUG: Size changed to \(viewSize.totalHeight)") sizeChanged(viewSize.totalHeight) } } - @State var notifyMeAbout: String = "Direct Messages" - @State var playNotificationSounds: Bool = false - @State var profileImageSize: String = "Large" - var body: some View { - Text("Body Goes Here") + VStack(spacing: 0) { + Group { + Text("Report an Issue") + .font(.title2) + } + .frame(height: 70) + .frame(maxWidth: .infinity) + .background(Color.secondary.opacity(0.1)) + .background( + GeometryReader { proxy in + Color.green.onAppear { + viewSize.headerHeight = proxy.size.height + } + } + ) + + Divider() + + Group { + Picker(selection: $viewModel.selectedFeedbackCategory, content: { + ForEach(VPNFeedbackFormViewModel.FeedbackCategory.allCases, id: \.self) { option in + Text(option.displayName).tag(option) + } + }, label: {}) + .controlSize(.large) + .padding(20) - Picker(selection: $viewModel.selectedOption) { - ForEach(viewModel.options) { option in - Text(option.title).tag(option.title) + switch viewModel.selectedFeedbackCategory { + case .landingPage: + Spacer() + .frame(height: 50) + case .failsToConnect: + VPNFeedbackFormIssueDescriptionForm() + case .tooSlow: + VPNFeedbackFormIssueDescriptionForm() + case .issueWithAppOrWebsite: + VPNFeedbackFormIssueDescriptionForm() + case .cantConnectToLocalDevice: + VPNFeedbackFormIssueDescriptionForm() + case .appCrashesOrFreezes: + VPNFeedbackFormIssueDescriptionForm() + case .featureRequest: + VPNFeedbackFormIssueDescriptionForm() + case .somethingElse: + VPNFeedbackFormIssueDescriptionForm() + } } + .padding([.leading, .trailing, .bottom], 20) + .background( + GeometryReader { proxy in + Color.red.onAppear { + print("DEBUG: Changing body view height") + viewSize.viewHeight = proxy.size.height + } + } + ) + + VPNFeedbackFormButtons() + .padding(20) + .background( + GeometryReader { proxy in + Color.blue.onAppear { + viewSize.buttonsHeight = proxy.size.height + } + } + ) + } + } + +} + +private struct VPNFeedbackFormIssueDescriptionForm: View { + + @State var text = "" + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Please describe what's happening, what you expected to happen, and the steps that led to the issue:") + .multilineTextAlignment(.leading) + .lineLimit(nil) + + TextField("Your issue goes here...", text: $text) + .textFieldStyle(.roundedBorder) + .lineLimit(10) + + Text("In addition to the details entered into this form, your app issue report will contain:") + Text("• Bullet one") + Text("• Bullet two") + Text("• Bullet three") + + Text("By clicking \"Submit\" I agree that DuckDuckGo may use the information in this report for purposes of improving the app's features.") + } + } + +} + +private struct VPNFeedbackFormButtons: View { + + @EnvironmentObject var viewModel: VPNFeedbackFormViewModel + + var body: some View { + HStack { + Button(action: { + viewModel.process(action: .cancel) + }, label: { + Text("Cancel") + .frame(maxWidth: .infinity) + }) + .controlSize(.large) + .frame(maxWidth: .infinity) + + Button(action: { + viewModel.process(action: .submit) + }, label: { + Text("Submit") + .frame(maxWidth: .infinity) + }) + .keyboardShortcut(.defaultAction) + .controlSize(.large) + .frame(maxWidth: .infinity) } } diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift index aac786889f..8f2a20b6b0 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift @@ -22,20 +22,21 @@ import SwiftUI final class FeedbackFormViewController: NSViewController { - private let defaultSize = CGSize(width: 550, height: 280) - private let viewModel: FeedbackFormViewModel + private let defaultSize = CGSize(width: 480, height: 280) + private let viewModel: VPNFeedbackFormViewModel private var heightConstraint: NSLayoutConstraint? - init(formOptions: [FeedbackFormViewModel.FeedbackFormOption]) { - self.viewModel = FeedbackFormViewModel(options: formOptions) + init() { + self.viewModel = VPNFeedbackFormViewModel() 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)) } @@ -65,7 +66,16 @@ final class FeedbackFormViewController: NSViewController { } private func updateViewHeight(height: CGFloat) { + print("DEBUG: Updating view height to \(height)") heightConstraint?.constant = height } } + +extension FeedbackFormViewController: VPNFeedbackFormViewModelDelegate { + + func vpnFeedbackViewModelDismissedView(_ viewModel: VPNFeedbackFormViewModel) { + dismiss() + } + +} diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift new file mode 100644 index 0000000000..fc6f07fd09 --- /dev/null +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift @@ -0,0 +1,86 @@ +// +// VPNFeedbackFormViewModel.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 + +protocol VPNFeedbackFormViewModelDelegate: AnyObject { + func vpnFeedbackViewModelDismissedView(_ viewModel: VPNFeedbackFormViewModel) +} + +final class VPNFeedbackFormViewModel: ObservableObject { + + enum FeedbackCategory: String, CaseIterable { + case landingPage + case failsToConnect + case tooSlow + case issueWithAppOrWebsite + case cantConnectToLocalDevice + case appCrashesOrFreezes + case featureRequest + case somethingElse + + var displayName: String { + switch self { + case .landingPage: return "What's happening?" + case .failsToConnect: return "VPN fails to connect" + case .tooSlow: return "VPN is too slow" + case .issueWithAppOrWebsite: return "Issue with app or website" + case .cantConnectToLocalDevice: return "Can't connect to local device" + case .appCrashesOrFreezes: return "App crashes or freezes" + case .featureRequest: return "Feature request" + case .somethingElse: return "Something else" + } + } + } + + enum ViewState { + case feedbackPending + case feedbackSent + } + + enum ViewAction { + case cancel + case submit + } + + @Published var viewState: ViewState + + @Published var selectedFeedbackCategory: FeedbackCategory { + didSet { + print("DEBUG: Changing option: \(selectedFeedbackCategory)") + } + } + + weak var delegate: VPNFeedbackFormViewModelDelegate? + + init() { + self.viewState = .feedbackPending + self.selectedFeedbackCategory = .landingPage + } + + func process(action: ViewAction) { + switch action { + case .cancel: + delegate?.vpnFeedbackViewModelDismissedView(self) + case .submit: break + } + } + +} From 207d04bcfabcf564dbbb175116f92293d189727c Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Fri, 1 Dec 2023 09:52:42 -0800 Subject: [PATCH 03/34] Tweaing view stuff. --- .../FeedbackFormView.swift | 54 ++++++++++++------- .../FeedbackFormViewController.swift | 2 +- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift index 2e159a13be..86fe5d2eab 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift @@ -21,19 +21,13 @@ import SwiftUI struct FeedbackFormView: View { - private enum Constants { - static let headerPadding = 20.0 - static let bodyPadding = 20.0 - } - struct ViewSize { fileprivate(set) var headerHeight: Double = 0.0 fileprivate(set) var viewHeight: Double = 0.0 - fileprivate(set) var spacerHeight: Double = 0.0 fileprivate(set) var buttonsHeight: Double = 0.0 var totalHeight: Double { - headerHeight + viewHeight + spacerHeight + buttonsHeight + 70 + headerHeight + viewHeight + buttonsHeight + 80 } } @@ -59,7 +53,8 @@ struct FeedbackFormView: View { .background(Color.secondary.opacity(0.1)) .background( GeometryReader { proxy in - Color.green.onAppear { + Color.clear.onAppear { + print("DEBUG: Header height \(proxy.size.height)") viewSize.headerHeight = proxy.size.height } } @@ -74,7 +69,7 @@ struct FeedbackFormView: View { } }, label: {}) .controlSize(.large) - .padding(20) + .padding(.bottom, 20) switch viewModel.selectedFeedbackCategory { case .landingPage: @@ -96,21 +91,24 @@ struct FeedbackFormView: View { VPNFeedbackFormIssueDescriptionForm() } } - .padding([.leading, .trailing, .bottom], 20) + .padding([.top, .leading, .trailing], 20) .background( GeometryReader { proxy in - Color.red.onAppear { - print("DEBUG: Changing body view height") + Color.clear.onAppear { + print("DEBUG: Body height \(proxy.size.height)") viewSize.viewHeight = proxy.size.height } } ) + Spacer() + VPNFeedbackFormButtons() .padding(20) .background( GeometryReader { proxy in - Color.blue.onAppear { + Color.clear.onAppear { + print("DEBUG: Button height \(proxy.size.height)") viewSize.buttonsHeight = proxy.size.height } } @@ -129,17 +127,33 @@ private struct VPNFeedbackFormIssueDescriptionForm: View { Text("Please describe what's happening, what you expected to happen, and the steps that led to the issue:") .multilineTextAlignment(.leading) .lineLimit(nil) - - TextField("Your issue goes here...", text: $text) - .textFieldStyle(.roundedBorder) - .lineLimit(10) + .fixedSize(horizontal: false, vertical: true) + + TextEditor(text: $text) + .frame(height: 80) + //.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) +// .background( +// ZStack { +// RoundedRectangle(cornerRadius: cornerRadius).stroke(Color(NSColor.textEditorBorderColor), lineWidth: 1) +// RoundedRectangle(cornerRadius: cornerRadius).fill(Color(NSColor.textEditorBackgroundColor)) +// } +// ) Text("In addition to the details entered into this form, your app issue report will contain:") - Text("• Bullet one") - Text("• Bullet two") - Text("• Bullet three") + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading) { + Text("• Bullet one") + Text("• Bullet two") + Text("• Bullet three") + } Text("By clicking \"Submit\" I agree that DuckDuckGo may use the information in this report for purposes of improving the app's features.") + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift index 8f2a20b6b0..c5285c96d2 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift @@ -22,7 +22,7 @@ import SwiftUI final class FeedbackFormViewController: NSViewController { - private let defaultSize = CGSize(width: 480, height: 280) + private let defaultSize = CGSize(width: 480, height: 348) private let viewModel: VPNFeedbackFormViewModel private var heightConstraint: NSLayoutConstraint? From f89883505468e108fac252121aa4eb4e13aa40c3 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Fri, 1 Dec 2023 19:28:16 -0800 Subject: [PATCH 04/34] Working on more UI. --- .../FeedbackFormView.swift | 223 ++++++++++++------ .../VPNFeedbackFormViewModel.swift | 40 +++- .../PasswordManagementLoginItemView.swift | 2 +- 3 files changed, 190 insertions(+), 75 deletions(-) diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift index 86fe5d2eab..25bd60ece7 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift @@ -27,7 +27,8 @@ struct FeedbackFormView: View { fileprivate(set) var buttonsHeight: Double = 0.0 var totalHeight: Double { - headerHeight + viewHeight + buttonsHeight + 80 + print("DEBUG: Calculating total height, header = \(headerHeight), view = \(viewHeight), buttons = \(buttonsHeight)") + return headerHeight + viewHeight + buttonsHeight + 80 } } @@ -37,7 +38,6 @@ struct FeedbackFormView: View { @State var viewSize: ViewSize = .init() { didSet { - print("DEBUG: Size changed to \(viewSize.totalHeight)") sizeChanged(viewSize.totalHeight) } } @@ -53,8 +53,7 @@ struct FeedbackFormView: View { .background(Color.secondary.opacity(0.1)) .background( GeometryReader { proxy in - Color.clear.onAppear { - print("DEBUG: Header height \(proxy.size.height)") + Color.blue.onAppear { viewSize.headerHeight = proxy.size.height } } @@ -62,53 +61,36 @@ struct FeedbackFormView: View { Divider() - Group { - Picker(selection: $viewModel.selectedFeedbackCategory, content: { - ForEach(VPNFeedbackFormViewModel.FeedbackCategory.allCases, id: \.self) { option in - Text(option.displayName).tag(option) + switch viewModel.viewState { + case .feedbackPending, .feedbackSending: + VPNFeedbackFormBodyView() + .padding([.top, .leading, .trailing], 20) + .background( + GeometryReader { proxy in + Color.red.onAppear { + viewSize.viewHeight = proxy.size.height + } } - }, label: {}) - .controlSize(.large) - .padding(.bottom, 20) - - switch viewModel.selectedFeedbackCategory { - case .landingPage: - Spacer() - .frame(height: 50) - case .failsToConnect: - VPNFeedbackFormIssueDescriptionForm() - case .tooSlow: - VPNFeedbackFormIssueDescriptionForm() - case .issueWithAppOrWebsite: - VPNFeedbackFormIssueDescriptionForm() - case .cantConnectToLocalDevice: - VPNFeedbackFormIssueDescriptionForm() - case .appCrashesOrFreezes: - VPNFeedbackFormIssueDescriptionForm() - case .featureRequest: - VPNFeedbackFormIssueDescriptionForm() - case .somethingElse: - VPNFeedbackFormIssueDescriptionForm() - } + ) + case .feedbackSent: + VPNFeedbackFormSentView() + .padding([.top, .leading, .trailing], 20) + .background( + GeometryReader { proxy in + Color.green.onAppear { + viewSize.viewHeight = proxy.size.height + } + } + ) } - .padding([.top, .leading, .trailing], 20) - .background( - GeometryReader { proxy in - Color.clear.onAppear { - print("DEBUG: Body height \(proxy.size.height)") - viewSize.viewHeight = proxy.size.height - } - } - ) - Spacer() + Spacer(minLength: 0) VPNFeedbackFormButtons() .padding(20) .background( GeometryReader { proxy in - Color.clear.onAppear { - print("DEBUG: Button height \(proxy.size.height)") + Color.yellow.onAppear { viewSize.buttonsHeight = proxy.size.height } } @@ -118,9 +100,47 @@ struct FeedbackFormView: View { } +private struct VPNFeedbackFormBodyView: View { + + @EnvironmentObject var viewModel: VPNFeedbackFormViewModel + + var body: some View { + Group { + Picker(selection: $viewModel.selectedFeedbackCategory, content: { + ForEach(VPNFeedbackFormViewModel.FeedbackCategory.allCases, id: \.self) { option in + Text(option.displayName).tag(option) + } + }, label: {}) + .controlSize(.large) + .padding(.bottom, 0) + + switch viewModel.selectedFeedbackCategory { + case .landingPage: + Spacer() + .frame(height: 50) + case .failsToConnect: + VPNFeedbackFormIssueDescriptionForm() + case .tooSlow: + VPNFeedbackFormIssueDescriptionForm() + case .issueWithAppOrWebsite: + VPNFeedbackFormIssueDescriptionForm() + case .cantConnectToLocalDevice: + VPNFeedbackFormIssueDescriptionForm() + case .appCrashesOrFreezes: + VPNFeedbackFormIssueDescriptionForm() + case .featureRequest: + VPNFeedbackFormIssueDescriptionForm() + case .somethingElse: + VPNFeedbackFormIssueDescriptionForm() + } + } + } + +} + private struct VPNFeedbackFormIssueDescriptionForm: View { - @State var text = "" + @EnvironmentObject var viewModel: VPNFeedbackFormViewModel var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -129,15 +149,11 @@ private struct VPNFeedbackFormIssueDescriptionForm: View { .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) - TextEditor(text: $text) - .frame(height: 80) - //.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) -// .background( -// ZStack { -// RoundedRectangle(cornerRadius: cornerRadius).stroke(Color(NSColor.textEditorBorderColor), lineWidth: 1) -// RoundedRectangle(cornerRadius: cornerRadius).fill(Color(NSColor.textEditorBackgroundColor)) -// } -// ) + if #available(macOS 12, *) { + FocusableTextEditor(text: $viewModel.feedbackFormText) + } else { + // TODO: Add macOS 11 editor + } Text("In addition to the details entered into this form, your app issue report will contain:") .multilineTextAlignment(.leading) @@ -159,30 +175,97 @@ private struct VPNFeedbackFormIssueDescriptionForm: View { } +private struct VPNFeedbackFormSentView: View { + + var body: some View { + VStack { + Image("JoinWaitlistHeader") + + Text("Thank you!") + .font(.system(size: 18, weight: .medium)) + .padding(.top, 20) + + Text("Your feedback will help us improve the\nDuckDuckGo app.") + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 10) + } + } + +} + +@available(macOS 12, *) +private struct FocusableTextEditor: View { + + @Binding var text: String + @FocusState var isFocused: Bool + + let cornerRadius: CGFloat = 8.0 + let borderWidth: CGFloat = 0.4 + let characterLimit: Int = 10000 + + var body: some View { + TextEditor(text: $text) + .frame(height: 150.0) + .font(.body) + .foregroundColor(.primary) + .focused($isFocused) + .padding(EdgeInsets(top: 3.0, leading: 6.0, bottom: 5.0, trailing: 0.0)) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .onChange(of: text) { + text = String($0.prefix(characterLimit)) + } + .background( + ZStack { + RoundedRectangle(cornerRadius: cornerRadius).stroke(Color.accentColor.opacity(0.5), lineWidth: 4).opacity(isFocused ? 1 : 0).scaleEffect(isFocused ? 1 : 1.04) + .animation(isFocused ? .easeIn(duration: 0.2) : .easeOut(duration: 0.0), value: isFocused) + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color(NSColor.textEditorBorderColor), lineWidth: borderWidth) + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color(NSColor.textEditorBackgroundColor)) + } + ) + } +} + private struct VPNFeedbackFormButtons: View { @EnvironmentObject var viewModel: VPNFeedbackFormViewModel var body: some View { HStack { - Button(action: { - viewModel.process(action: .cancel) - }, label: { - Text("Cancel") - .frame(maxWidth: .infinity) - }) - .controlSize(.large) - .frame(maxWidth: .infinity) + if viewModel.viewState == .feedbackSent { + Button(action: { + viewModel.process(action: .cancel) + }, label: { + Text("Done") + .frame(maxWidth: .infinity) + }) + .keyboardShortcut(.defaultAction) + .controlSize(.large) + .frame(maxWidth: .infinity) + } else { + Button(action: { + viewModel.process(action: .cancel) + }, label: { + Text("Cancel") + .frame(maxWidth: .infinity) + }) + .controlSize(.large) + .frame(maxWidth: .infinity) - Button(action: { - viewModel.process(action: .submit) - }, label: { - Text("Submit") - .frame(maxWidth: .infinity) - }) - .keyboardShortcut(.defaultAction) - .controlSize(.large) - .frame(maxWidth: .infinity) + Button(action: { + viewModel.process(action: .submit) + }, label: { + Text(viewModel.viewState == .feedbackSending ? "Submitting..." : "Submit") + .frame(maxWidth: .infinity) + }) + .keyboardShortcut(.defaultAction) + .controlSize(.large) + .frame(maxWidth: .infinity) + .disabled(!viewModel.submitButtonEnabled) + } } } diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift index fc6f07fd09..142286461d 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift @@ -36,6 +36,21 @@ final class VPNFeedbackFormViewModel: ObservableObject { case featureRequest case somethingElse + var isFeedbackCategory: Bool { + switch self { + case .landingPage: + return false + case .failsToConnect, + .tooSlow, + .issueWithAppOrWebsite, + .cantConnectToLocalDevice, + .appCrashesOrFreezes, + .featureRequest, + .somethingElse: + return true + } + } + var displayName: String { switch self { case .landingPage: return "What's happening?" @@ -52,6 +67,7 @@ final class VPNFeedbackFormViewModel: ObservableObject { enum ViewState { case feedbackPending + case feedbackSending case feedbackSent } @@ -60,14 +76,21 @@ final class VPNFeedbackFormViewModel: ObservableObject { case submit } - @Published var viewState: ViewState + @Published var viewState: ViewState { + didSet { + updateSubmitButtonStatus() + } + } - @Published var selectedFeedbackCategory: FeedbackCategory { + @Published var feedbackFormText: String = "" { didSet { - print("DEBUG: Changing option: \(selectedFeedbackCategory)") + updateSubmitButtonStatus() } } + @Published private(set) var submitButtonEnabled: Bool = false + @Published var selectedFeedbackCategory: FeedbackCategory + weak var delegate: VPNFeedbackFormViewModelDelegate? init() { @@ -79,8 +102,17 @@ final class VPNFeedbackFormViewModel: ObservableObject { switch action { case .cancel: delegate?.vpnFeedbackViewModelDismissedView(self) - case .submit: break + case .submit: + self.viewState = .feedbackSending + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.viewState = .feedbackSent + } } } + private func updateSubmitButtonStatus() { + self.submitButtonEnabled = (viewState == .feedbackPending) && !feedbackFormText.isEmpty + } + } diff --git a/DuckDuckGo/SecureVault/View/PasswordManagementLoginItemView.swift b/DuckDuckGo/SecureVault/View/PasswordManagementLoginItemView.swift index 1504c0160b..eb28665527 100644 --- a/DuckDuckGo/SecureVault/View/PasswordManagementLoginItemView.swift +++ b/DuckDuckGo/SecureVault/View/PasswordManagementLoginItemView.swift @@ -510,7 +510,7 @@ private struct NotesView: View { } @available(macOS 12, *) -struct FocusableTextEditor: View { +private struct FocusableTextEditor: View { @EnvironmentObject var model: PasswordManagementLoginModel @FocusState var isFocused: Bool From 978c930558c23703717fbc6d402dc225924d16e4 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Fri, 1 Dec 2023 19:56:05 -0800 Subject: [PATCH 05/34] Tweaking height calculation. --- .../FeedbackFormView.swift | 15 +++++++------- .../FeedbackFormViewController.swift | 20 +++++++++++++++++-- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift index 25bd60ece7..37d06079f1 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift @@ -27,7 +27,6 @@ struct FeedbackFormView: View { fileprivate(set) var buttonsHeight: Double = 0.0 var totalHeight: Double { - print("DEBUG: Calculating total height, header = \(headerHeight), view = \(viewHeight), buttons = \(buttonsHeight)") return headerHeight + viewHeight + buttonsHeight + 80 } } @@ -53,7 +52,7 @@ struct FeedbackFormView: View { .background(Color.secondary.opacity(0.1)) .background( GeometryReader { proxy in - Color.blue.onAppear { + Color.clear.onAppear { viewSize.headerHeight = proxy.size.height } } @@ -67,7 +66,7 @@ struct FeedbackFormView: View { .padding([.top, .leading, .trailing], 20) .background( GeometryReader { proxy in - Color.red.onAppear { + Color.clear.onAppear { viewSize.viewHeight = proxy.size.height } } @@ -77,7 +76,7 @@ struct FeedbackFormView: View { .padding([.top, .leading, .trailing], 20) .background( GeometryReader { proxy in - Color.green.onAppear { + Color.clear.onAppear { viewSize.viewHeight = proxy.size.height } } @@ -90,7 +89,7 @@ struct FeedbackFormView: View { .padding(20) .background( GeometryReader { proxy in - Color.yellow.onAppear { + Color.clear.onAppear { viewSize.buttonsHeight = proxy.size.height } } @@ -178,15 +177,15 @@ private struct VPNFeedbackFormIssueDescriptionForm: View { private struct VPNFeedbackFormSentView: View { var body: some View { - VStack { + VStack(spacing: 0) { Image("JoinWaitlistHeader") Text("Thank you!") .font(.system(size: 18, weight: .medium)) - .padding(.top, 20) + .padding(.top, 30) Text("Your feedback will help us improve the\nDuckDuckGo app.") - .multilineTextAlignment(.leading) + .multilineTextAlignment(.center) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) .padding(.top, 10) diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift index c5285c96d2..e222c41316 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift @@ -22,6 +22,12 @@ import SwiftUI final class FeedbackFormViewController: NSViewController { + enum Constants { + static let landingPageHeight = 260.0 + static let feedbackFormHeight = 550.0 + static let feedbackSentHeight = 340.0 + } + private let defaultSize = CGSize(width: 480, height: 348) private let viewModel: VPNFeedbackFormViewModel @@ -66,8 +72,18 @@ final class FeedbackFormViewController: NSViewController { } private func updateViewHeight(height: CGFloat) { - print("DEBUG: Updating view height to \(height)") - heightConstraint?.constant = height + switch viewModel.viewState { + case .feedbackPending: + if viewModel.selectedFeedbackCategory == .landingPage { + heightConstraint?.constant = Constants.landingPageHeight + } else { + heightConstraint?.constant = Constants.feedbackFormHeight + } + case .feedbackSending: + heightConstraint?.constant = Constants.feedbackFormHeight + case .feedbackSent: + heightConstraint?.constant = Constants.feedbackSentHeight + } } } From f7d123660237d2f5571c8c23dab796a2e5e57b3c Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Fri, 1 Dec 2023 21:05:03 -0800 Subject: [PATCH 06/34] Add a metadata collector. --- DuckDuckGo.xcodeproj/project.pbxproj | 14 +++ .../MainWindow/MainViewController.swift | 6 + .../NetworkProtectionDebugMenu.swift | 5 +- .../VPNMetadataCollector.swift | 113 ++++++++++++++++++ .../InputFilesChecker/InputFilesChecker.swift | 1 + 5 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector/VPNMetadataCollector.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 2db8adcdf2..d12ba89795 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1065,6 +1065,8 @@ 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 */; }; + 4B05265F2B1AEFDB0054955A /* VPNMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B05265D2B1AE5C70054955A /* VPNMetadataCollector.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 */; }; @@ -3309,6 +3311,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 = ""; }; 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 = ""; }; @@ -4983,6 +4986,14 @@ path = Preferences; sourceTree = ""; }; + 4B05265C2B1AE5B10054955A /* VPNMetadataCollector */ = { + isa = PBXGroup; + children = ( + 4B05265D2B1AE5C70054955A /* VPNMetadataCollector.swift */, + ); + path = VPNMetadataCollector; + sourceTree = ""; + }; 4B18E3222A1D31E4005D0AAA /* NetworkProtection */ = { isa = PBXGroup; children = ( @@ -5050,6 +5061,7 @@ 4B41EDAD2B168AFF001EEDF4 /* FeedbackFormViewController.swift */, 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */, 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */, + 4B05265C2B1AE5B10054955A /* VPNMetadataCollector */, ); path = NetworkProtectionFeedbackForm; sourceTree = ""; @@ -10366,6 +10378,7 @@ 4B9579DD2AC7AE700062CA31 /* SpacerNode.swift in Sources */, B62B483C2ADE46FC000DECE5 /* Application.swift in Sources */, 4B9579DF2AC7AE700062CA31 /* SyncManagementDialogViewController.swift in Sources */, + 4B05265F2B1AEFDB0054955A /* VPNMetadataCollector.swift in Sources */, 4B9579E02AC7AE700062CA31 /* BookmarkExtension.swift in Sources */, 4B9579E12AC7AE700062CA31 /* PasswordManagementCreditCardModel.swift in Sources */, 4B9579E22AC7AE700062CA31 /* NSEventExtension.swift in Sources */, @@ -11409,6 +11422,7 @@ 4B9292A226670D2A00AD2C21 /* PseudoFolder.swift in Sources */, AA7E919A2875B39300AB6B62 /* Visit.swift in Sources */, 4BCF15D92ABB8A7F0083F6DF /* NetworkProtectionRemoteMessage.swift in Sources */, + 4B05265E2B1AE5C70054955A /* VPNMetadataCollector.swift in Sources */, B6DA44022616B28300DD1EC2 /* PixelDataStore.swift in Sources */, 4B9DB0292A983B24000927DB /* WaitlistStorage.swift in Sources */, B6A9E45326142B070067D1B9 /* Pixel.swift in Sources */, diff --git a/DuckDuckGo/MainWindow/MainViewController.swift b/DuckDuckGo/MainWindow/MainViewController.swift index 19b083b338..9bf43a84b6 100644 --- a/DuckDuckGo/MainWindow/MainViewController.swift +++ b/DuckDuckGo/MainWindow/MainViewController.swift @@ -126,6 +126,12 @@ final class MainViewController: NSViewController { override func viewDidLayout() { findInPageContainerView.applyDropShadow() + + Task { + let metadataCollector = VPNMetadataCollector() + await metadataCollector.collectNetworkInformation() + print("done") + } } func windowDidBecomeMain() { diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift index 1a4b44891d..ef9537940c 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift @@ -237,13 +237,11 @@ final class NetworkProtectionDebugMenu: NSMenu { } @objc func openNativeFeedbackForm(_ sender: Any?) { - print("TODO") let feedbackFormViewController = FeedbackFormViewController() let feedbackFormWindowController = feedbackFormViewController.wrappedInWindowController() guard let feedbackFormWindow = feedbackFormWindowController.window, - let parentWindowController = WindowControllersManager.shared.lastKeyMainWindowController - else { + let parentWindowController = WindowControllersManager.shared.lastKeyMainWindowController else { assertionFailure("Failed to present native VPN feedback form") return } @@ -251,7 +249,6 @@ final class NetworkProtectionDebugMenu: NSMenu { parentWindowController.window?.beginSheet(feedbackFormWindow) { [weak self] _ in print("DEBUG: Form closed") } - } /// Sets the selected server. diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector/VPNMetadataCollector.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector/VPNMetadataCollector.swift new file mode 100644 index 0000000000..0148d40395 --- /dev/null +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector/VPNMetadataCollector.swift @@ -0,0 +1,113 @@ +// +// VPNMetadataCollector.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 Common + +struct VPNMetadata: Encodable { + + struct AppInfo: Encodable { + let appVersion: String + let isInternalUser: Bool + } + + struct DeviceInfo: Encodable { + let osVersion: String + let buildFlavor: String + let lowPowerModeEnabled: Bool + } + + let appInfo: AppInfo + let deviceInfo: DeviceInfo + + func toBase64() -> String { + fatalError() + } + +} + +struct VPNMetadataCollector { + + func collectMetadata() async -> VPNMetadata { + let appInfoMetadata = collectAppInfoMetadata() + let deviceInfoMetadata = collectDeviceInfoMetadata() + + return VPNMetadata( + appInfo: appInfoMetadata, + deviceInfo: deviceInfoMetadata + ) + } + + // MARK: - Metadata Collection + + private func collectAppInfoMetadata() -> VPNMetadata.AppInfo { + let appVersion = AppVersion.shared.versionAndBuildNumber + let isInternalUser = NSApp.delegateTyped.internalUserDecider.isInternalUser + + return .init(appVersion: appVersion, isInternalUser: isInternalUser) + } + + private func collectDeviceInfoMetadata() -> VPNMetadata.DeviceInfo { +#if APPSTORE + let buildFlavor: String = "appstore" +#else + let buildFlavor: String = "dmg" +#endif + + let osVersion = AppVersion.shared.osVersion + let lowPowerModeEnabled: Bool + + if #available(macOS 12.0, *) { + lowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled + } else { + lowPowerModeEnabled = false + } + + return .init(osVersion: osVersion, buildFlavor: buildFlavor, lowPowerModeEnabled: lowPowerModeEnabled) + } + + func collectNetworkInformation() async { + print("DEBUG: Network Information") + + let monitor = NWPathMonitor() + for await path in monitor.paths() { + print(path.debugDescription) + } + + print("Done!") + } + +} + +extension NWPathMonitor { + + fileprivate func paths() -> AsyncStream { + AsyncStream { continuation in + pathUpdateHandler = { path in + continuation.yield(path) + } + + continuation.onTermination = { [weak self] _ in + self?.cancel() + } + + start(queue: DispatchQueue(label: "VPNMetadataCollector.NWPathMonitor.paths")) + } + } + +} diff --git a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift index 5eb9a976e9..575effcf83 100644 --- a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift +++ b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift @@ -41,6 +41,7 @@ let nonSandboxedExtraInputFiles: Set = [ .init("DataBrokerProtectionFeatureVisibility.swift", .source), .init("DataBrokerProtectionFeatureDisabler.swift", .source), .init("DataBrokerProtectionAppEvents.swift", .source), + .init("VPNMetadataCollector.swift", .source), .init("DuckDuckGoDBPBackgroundAgent.app", .unknown) ] From ebd0886661d29d7504502cedfa2efa7cd50bb66f Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Sun, 3 Dec 2023 18:50:55 -0800 Subject: [PATCH 07/34] Even more work on metadata. --- DuckDuckGo.xcodeproj/project.pbxproj | 22 ++- .../MainWindow/MainViewController.swift | 6 - .../FeedbackFormView.swift | 8 +- .../FeedbackFormViewController.swift | 6 + .../VPNFeedbackCategory.swift | 62 +++++++ .../VPNFeedbackFormViewModel.swift | 64 +++---- .../VPNFeedbackSender.swift | 35 ++++ .../VPNMetadataCollector.swift | 170 ++++++++++++++++++ .../VPNMetadataCollector.swift | 113 ------------ DuckDuckGo/Statistics/PixelEvent.swift | 6 + DuckDuckGo/Statistics/PixelParameters.swift | 7 + .../InputFilesChecker/InputFilesChecker.swift | 2 + .../PixelKit/PixelKit+Parameters.swift | 5 + .../SystemExtensionManager.swift | 2 +- 14 files changed, 334 insertions(+), 174 deletions(-) create mode 100644 DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackCategory.swift create mode 100644 DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackSender.swift create mode 100644 DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector.swift delete mode 100644 DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector/VPNMetadataCollector.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index d12ba89795..6e8719d679 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1067,6 +1067,10 @@ 4B0511E2262CAA8600F6079C /* NSViewControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0511E0262CAA8600F6079C /* NSViewControllerExtension.swift */; }; 4B05265E2B1AE5C70054955A /* VPNMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B05265D2B1AE5C70054955A /* VPNMetadataCollector.swift */; }; 4B05265F2B1AEFDB0054955A /* VPNMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B05265D2B1AE5C70054955A /* VPNMetadataCollector.swift */; }; + 4B0526612B1D55320054955A /* VPNFeedbackSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0526602B1D55320054955A /* VPNFeedbackSender.swift */; }; + 4B0526622B1D55320054955A /* VPNFeedbackSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0526602B1D55320054955A /* VPNFeedbackSender.swift */; }; + 4B0526642B1D55D80054955A /* VPNFeedbackCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0526632B1D55D80054955A /* VPNFeedbackCategory.swift */; }; + 4B0526652B1D55D80054955A /* VPNFeedbackCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0526632B1D55D80054955A /* VPNFeedbackCategory.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 */; }; @@ -3312,6 +3316,8 @@ 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 = ""; }; 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 = ""; }; @@ -4986,14 +4992,6 @@ path = Preferences; sourceTree = ""; }; - 4B05265C2B1AE5B10054955A /* VPNMetadataCollector */ = { - isa = PBXGroup; - children = ( - 4B05265D2B1AE5C70054955A /* VPNMetadataCollector.swift */, - ); - path = VPNMetadataCollector; - sourceTree = ""; - }; 4B18E3222A1D31E4005D0AAA /* NetworkProtection */ = { isa = PBXGroup; children = ( @@ -5061,7 +5059,9 @@ 4B41EDAD2B168AFF001EEDF4 /* FeedbackFormViewController.swift */, 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */, 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */, - 4B05265C2B1AE5B10054955A /* VPNMetadataCollector */, + 4B0526632B1D55D80054955A /* VPNFeedbackCategory.swift */, + 4B05265D2B1AE5C70054955A /* VPNMetadataCollector.swift */, + 4B0526602B1D55320054955A /* VPNFeedbackSender.swift */, ); path = NetworkProtectionFeedbackForm; sourceTree = ""; @@ -10449,6 +10449,7 @@ 4B957A202AC7AE700062CA31 /* CancellableExtension.swift in Sources */, 4B957A212AC7AE700062CA31 /* PinnedTabsHostingView.swift in Sources */, 4B957A222AC7AE700062CA31 /* FirefoxBookmarksReader.swift in Sources */, + 4B0526622B1D55320054955A /* VPNFeedbackSender.swift in Sources */, 4B957A232AC7AE700062CA31 /* DeviceIdleStateDetector.swift in Sources */, 4B957A242AC7AE700062CA31 /* FlatButton.swift in Sources */, 4B957A252AC7AE700062CA31 /* PinnedTabView.swift in Sources */, @@ -10868,6 +10869,7 @@ 4B957BAF2AC7AE700062CA31 /* TabExtensions.swift in Sources */, 4B957BB02AC7AE700062CA31 /* TabBarViewItem.swift in Sources */, 4B957BB12AC7AE700062CA31 /* NSWindow+Toast.swift in Sources */, + 4B0526652B1D55D80054955A /* VPNFeedbackCategory.swift in Sources */, 4B957BB22AC7AE700062CA31 /* AutoconsentUserScript.swift in Sources */, 4B957BB32AC7AE700062CA31 /* BookmarksExporter.swift in Sources */, 4B957BB42AC7AE700062CA31 /* NetworkProtectionAppEvents.swift in Sources */, @@ -11004,6 +11006,7 @@ B693955126F04BEB0015B914 /* GradientView.swift in Sources */, 37AFCE8527DA2D3900471A10 /* PreferencesSidebar.swift in Sources */, B6C00ED5292FB21E009C73A6 /* HoveredLinkTabExtension.swift in Sources */, + 4B0526612B1D55320054955A /* VPNFeedbackSender.swift in Sources */, AA5C8F5E2590EEE800748EB7 /* NSPointExtension.swift in Sources */, 4BF0E5122AD25A2600FFEC9E /* DuckDuckGoUserAgent.swift in Sources */, AA6EF9AD25066F42004754E6 /* WindowsManager.swift in Sources */, @@ -11251,6 +11254,7 @@ AAB8203C26B2DE0D00788AC3 /* SuggestionListCharacteristics.swift in Sources */, AAADFD06264AA282001555EA /* TimeIntervalExtension.swift in Sources */, 4B6785472AA8DE68008A5004 /* NetworkProtectionFeatureDisabler.swift in Sources */, + 4B0526642B1D55D80054955A /* VPNFeedbackCategory.swift in Sources */, 4B9292D42667123700AD2C21 /* BookmarkListViewController.swift in Sources */, 4B723E0D26B0006100E14D75 /* SecureVaultLoginImporter.swift in Sources */, B645D8F629FA95440024461F /* WKProcessPoolExtension.swift in Sources */, diff --git a/DuckDuckGo/MainWindow/MainViewController.swift b/DuckDuckGo/MainWindow/MainViewController.swift index 9bf43a84b6..19b083b338 100644 --- a/DuckDuckGo/MainWindow/MainViewController.swift +++ b/DuckDuckGo/MainWindow/MainViewController.swift @@ -126,12 +126,6 @@ final class MainViewController: NSViewController { override func viewDidLayout() { findInPageContainerView.applyDropShadow() - - Task { - let metadataCollector = VPNMetadataCollector() - await metadataCollector.collectNetworkInformation() - print("done") - } } func windowDidBecomeMain() { diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift index 37d06079f1..093b892949 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift @@ -19,6 +19,8 @@ import Foundation import SwiftUI +#if NETWORK_PROTECTION + struct FeedbackFormView: View { struct ViewSize { @@ -61,7 +63,7 @@ struct FeedbackFormView: View { Divider() switch viewModel.viewState { - case .feedbackPending, .feedbackSending: + case .feedbackPending, .feedbackSending, .feedbackSendingFailed: VPNFeedbackFormBodyView() .padding([.top, .leading, .trailing], 20) .background( @@ -106,7 +108,7 @@ private struct VPNFeedbackFormBodyView: View { var body: some View { Group { Picker(selection: $viewModel.selectedFeedbackCategory, content: { - ForEach(VPNFeedbackFormViewModel.FeedbackCategory.allCases, id: \.self) { option in + ForEach(VPNFeedbackCategory.allCases, id: \.self) { option in Text(option.displayName).tag(option) } }, label: {}) @@ -269,3 +271,5 @@ private struct VPNFeedbackFormButtons: View { } } + +#endif diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift index e222c41316..30d2af0769 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift @@ -16,6 +16,8 @@ // limitations under the License. // +#if NETWORK_PROTECTION + import Foundation import AppKit import SwiftUI @@ -83,6 +85,8 @@ final class FeedbackFormViewController: NSViewController { heightConstraint?.constant = Constants.feedbackFormHeight case .feedbackSent: heightConstraint?.constant = Constants.feedbackSentHeight + case .feedbackSendingFailed: + heightConstraint?.constant = Constants.feedbackFormHeight } } @@ -95,3 +99,5 @@ extension FeedbackFormViewController: VPNFeedbackFormViewModelDelegate { } } + +#endif diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackCategory.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackCategory.swift new file mode 100644 index 0000000000..aedf9765d6 --- /dev/null +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackCategory.swift @@ -0,0 +1,62 @@ +// +// VPNFeedbackCategory.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 + +#if NETWORK_PROTECTION + +enum VPNFeedbackCategory: String, CaseIterable { + case landingPage + case failsToConnect + case tooSlow + case issueWithAppOrWebsite + case cantConnectToLocalDevice + case appCrashesOrFreezes + case featureRequest + case somethingElse + + var isFeedbackCategory: Bool { + switch self { + case .landingPage: + return false + case .failsToConnect, + .tooSlow, + .issueWithAppOrWebsite, + .cantConnectToLocalDevice, + .appCrashesOrFreezes, + .featureRequest, + .somethingElse: + return true + } + } + + var displayName: String { + switch self { + case .landingPage: return "What's happening?" + case .failsToConnect: return "VPN fails to connect" + case .tooSlow: return "VPN is too slow" + case .issueWithAppOrWebsite: return "Issue with app or website" + case .cantConnectToLocalDevice: return "Can't connect to local device" + case .appCrashesOrFreezes: return "App crashes or freezes" + case .featureRequest: return "Feature request" + case .somethingElse: return "Something else" + } + } +} + +#endif diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift index 142286461d..b00f820d86 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift @@ -16,6 +16,8 @@ // limitations under the License. // +#if NETWORK_PROTECTION + import Foundation import Combine import SwiftUI @@ -26,48 +28,10 @@ protocol VPNFeedbackFormViewModelDelegate: AnyObject { final class VPNFeedbackFormViewModel: ObservableObject { - enum FeedbackCategory: String, CaseIterable { - case landingPage - case failsToConnect - case tooSlow - case issueWithAppOrWebsite - case cantConnectToLocalDevice - case appCrashesOrFreezes - case featureRequest - case somethingElse - - var isFeedbackCategory: Bool { - switch self { - case .landingPage: - return false - case .failsToConnect, - .tooSlow, - .issueWithAppOrWebsite, - .cantConnectToLocalDevice, - .appCrashesOrFreezes, - .featureRequest, - .somethingElse: - return true - } - } - - var displayName: String { - switch self { - case .landingPage: return "What's happening?" - case .failsToConnect: return "VPN fails to connect" - case .tooSlow: return "VPN is too slow" - case .issueWithAppOrWebsite: return "Issue with app or website" - case .cantConnectToLocalDevice: return "Can't connect to local device" - case .appCrashesOrFreezes: return "App crashes or freezes" - case .featureRequest: return "Feature request" - case .somethingElse: return "Something else" - } - } - } - enum ViewState { case feedbackPending case feedbackSending + case feedbackSendingFailed case feedbackSent } @@ -89,13 +53,19 @@ final class VPNFeedbackFormViewModel: ObservableObject { } @Published private(set) var submitButtonEnabled: Bool = false - @Published var selectedFeedbackCategory: FeedbackCategory + @Published var selectedFeedbackCategory: VPNFeedbackCategory weak var delegate: VPNFeedbackFormViewModelDelegate? - init() { + private let metadataCollector: VPNMetadataCollector + private let feedbackSender: VPNFeedbackSender + + init(metadataCollector: VPNMetadataCollector = DefaultVPNMetadataCollector(), feedbackSender: VPNFeedbackSender = DefaultVPNFeedbackSender()) { self.viewState = .feedbackPending self.selectedFeedbackCategory = .landingPage + + self.metadataCollector = metadataCollector + self.feedbackSender = feedbackSender } func process(action: ViewAction) { @@ -105,8 +75,14 @@ final class VPNFeedbackFormViewModel: ObservableObject { case .submit: self.viewState = .feedbackSending - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.viewState = .feedbackSent + Task { @MainActor in + do { + let metadata = await self.metadataCollector.collectMetadata() + try await self.feedbackSender.send(metadata: metadata, category: selectedFeedbackCategory, userText: feedbackFormText) + self.viewState = .feedbackSent + } catch { + self.viewState = .feedbackSendingFailed + } } } } @@ -116,3 +92,5 @@ final class VPNFeedbackFormViewModel: ObservableObject { } } + +#endif diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackSender.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackSender.swift new file mode 100644 index 0000000000..fe6a90bbdf --- /dev/null +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackSender.swift @@ -0,0 +1,35 @@ +// +// VPNFeedbackSender.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. +// + +#if NETWORK_PROTECTION + +import Foundation + +protocol VPNFeedbackSender { + func send(metadata: VPNMetadata, category: VPNFeedbackCategory, userText: String) async throws +} + +struct DefaultVPNFeedbackSender: VPNFeedbackSender { + + func send(metadata: VPNMetadata, category: VPNFeedbackCategory, userText: String) async throws { + print("[VPN] Sending metadata: \(metadata.toPrettyPrintedJSON() ?? "ERROR")") + } + +} + +#endif diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector.swift new file mode 100644 index 0000000000..fcfdf297ce --- /dev/null +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector.swift @@ -0,0 +1,170 @@ +// +// VPNMetadataCollector.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. +// + +#if NETWORK_PROTECTION + +import Foundation +import Common +import NetworkProtection +import NetworkProtectionIPC +import NetworkProtectionUI + +struct VPNMetadata: Encodable { + + struct AppInfo: Encodable { + let appVersion: String + let lastVersionRun: String + let isInternalUser: Bool + } + + struct DeviceInfo: Encodable { + let osVersion: String + let buildFlavor: String + let lowPowerModeEnabled: Bool + } + + struct NetworkInfo: Encodable { + let currentPath: String + } + + struct VPNState: Encodable { + let onboardingState: String + let vpnIsEnabled: Bool + } + + let appInfo: AppInfo + let deviceInfo: DeviceInfo + let networkInfo: NetworkInfo + let vpnState: VPNState + + // TODO: Agent status + // TODO: VPN configuration status + + func toPrettyPrintedJSON() -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + guard let encodedMetadata = try? encoder.encode(self) else { + assertionFailure("Failed to encode metadata") + return nil + } + + return String(data: encodedMetadata, encoding: .utf8) + } + + func toBase64() -> String { + fatalError() + } + +} + +protocol VPNMetadataCollector { + func collectMetadata() async -> VPNMetadata +} + +struct DefaultVPNMetadataCollector: VPNMetadataCollector { + + @MainActor + func collectMetadata() async -> VPNMetadata { + let appInfoMetadata = collectAppInfoMetadata() + let deviceInfoMetadata = collectDeviceInfoMetadata() + let networkInfoMetadata = await collectNetworkInformation() + let vpnState = await collectVPNState() + + return VPNMetadata( + appInfo: appInfoMetadata, + deviceInfo: deviceInfoMetadata, + networkInfo: networkInfoMetadata, + vpnState: vpnState + ) + } + + // MARK: - Metadata Collection + + private func collectAppInfoMetadata() -> VPNMetadata.AppInfo { + let appVersion = AppVersion.shared.versionNumber + let versionStore = NetworkProtectionLastVersionRunStore() + let isInternalUser = NSApp.delegateTyped.internalUserDecider.isInternalUser + + return .init(appVersion: appVersion, lastVersionRun: versionStore.lastVersionRun ?? "Unknown", isInternalUser: isInternalUser) + } + + private func collectDeviceInfoMetadata() -> VPNMetadata.DeviceInfo { +#if APPSTORE + let buildFlavor: String = "appstore" +#else + let buildFlavor: String = "dmg" +#endif + + let osVersion = AppVersion.shared.osVersion + let lowPowerModeEnabled: Bool + + if #available(macOS 12.0, *) { + lowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled + } else { + lowPowerModeEnabled = false + } + + return .init(osVersion: osVersion, buildFlavor: buildFlavor, lowPowerModeEnabled: lowPowerModeEnabled) + } + + func collectNetworkInformation() async -> VPNMetadata.NetworkInfo { + let monitor = NWPathMonitor() + monitor.start(queue: DispatchQueue(label: "VPNMetadataCollector.NWPathMonitor.paths")) + try? await Task.sleep(interval: .seconds(1)) + + let path = monitor.currentPath + monitor.cancel() + return .init(currentPath: path.debugDescription) + } + + @MainActor + func collectVPNState() async -> VPNMetadata.VPNState { + let onboardingState: String + + switch UserDefaults.netP.networkProtectionOnboardingStatus { + case .completed: + onboardingState = "complete" + case .isOnboarding(let step): + switch step { + case .userNeedsToAllowExtension: + onboardingState = "pending-extension-approval" + case .userNeedsToAllowVPNConfiguration: + onboardingState = "pending-vpn-approval" + } + } + + let machServiceName = Bundle.main.vpnMenuAgentBundleId + let ipcClient = TunnelControllerIPCClient(machServiceName: machServiceName) + let controller = NetworkProtectionIPCTunnelController(ipcClient: ipcClient) // TODO: Get correct isConnected value + let statusReporter = DefaultNetworkProtectionStatusReporter( + statusObserver: ipcClient.connectionStatusObserver, + serverInfoObserver: ipcClient.serverInfoObserver, + connectionErrorObserver: ipcClient.connectionErrorObserver, + connectivityIssuesObserver: ConnectivityIssueObserverThroughDistributedNotifications(), + controllerErrorMessageObserver: ControllerErrorMesssageObserverThroughDistributedNotifications() + ) + + try? await Task.sleep(interval: .seconds(1)) + + return .init(onboardingState: onboardingState, vpnIsEnabled: false) + } + +} + +#endif diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector/VPNMetadataCollector.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector/VPNMetadataCollector.swift deleted file mode 100644 index 0148d40395..0000000000 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector/VPNMetadataCollector.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// VPNMetadataCollector.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 Common - -struct VPNMetadata: Encodable { - - struct AppInfo: Encodable { - let appVersion: String - let isInternalUser: Bool - } - - struct DeviceInfo: Encodable { - let osVersion: String - let buildFlavor: String - let lowPowerModeEnabled: Bool - } - - let appInfo: AppInfo - let deviceInfo: DeviceInfo - - func toBase64() -> String { - fatalError() - } - -} - -struct VPNMetadataCollector { - - func collectMetadata() async -> VPNMetadata { - let appInfoMetadata = collectAppInfoMetadata() - let deviceInfoMetadata = collectDeviceInfoMetadata() - - return VPNMetadata( - appInfo: appInfoMetadata, - deviceInfo: deviceInfoMetadata - ) - } - - // MARK: - Metadata Collection - - private func collectAppInfoMetadata() -> VPNMetadata.AppInfo { - let appVersion = AppVersion.shared.versionAndBuildNumber - let isInternalUser = NSApp.delegateTyped.internalUserDecider.isInternalUser - - return .init(appVersion: appVersion, isInternalUser: isInternalUser) - } - - private func collectDeviceInfoMetadata() -> VPNMetadata.DeviceInfo { -#if APPSTORE - let buildFlavor: String = "appstore" -#else - let buildFlavor: String = "dmg" -#endif - - let osVersion = AppVersion.shared.osVersion - let lowPowerModeEnabled: Bool - - if #available(macOS 12.0, *) { - lowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled - } else { - lowPowerModeEnabled = false - } - - return .init(osVersion: osVersion, buildFlavor: buildFlavor, lowPowerModeEnabled: lowPowerModeEnabled) - } - - func collectNetworkInformation() async { - print("DEBUG: Network Information") - - let monitor = NWPathMonitor() - for await path in monitor.paths() { - print(path.debugDescription) - } - - print("Done!") - } - -} - -extension NWPathMonitor { - - fileprivate func paths() -> AsyncStream { - AsyncStream { continuation in - pathUpdateHandler = { path in - continuation.yield(path) - } - - continuation.onTermination = { [weak self] _ in - self?.cancel() - } - - start(queue: DispatchQueue(label: "VPNMetadataCollector.NWPathMonitor.paths")) - } - } - -} diff --git a/DuckDuckGo/Statistics/PixelEvent.swift b/DuckDuckGo/Statistics/PixelEvent.swift index ef02bd9b86..06552b07b5 100644 --- a/DuckDuckGo/Statistics/PixelEvent.swift +++ b/DuckDuckGo/Statistics/PixelEvent.swift @@ -159,6 +159,9 @@ extension Pixel { case dashboardProtectionAllowlistAdd(triggerOrigin: String?) case dashboardProtectionAllowlistRemove(triggerOrigin: String?) + // VPN + case vpnBreakageReport(category: String, description: String, metadata: String) + // Network Protection Waitlist case networkProtectionWaitlistUserActive case networkProtectionWaitlistEntryPointMenuItemDisplayed @@ -471,6 +474,9 @@ extension Pixel.Event { case .serpDay21to27: return "m.mac.search-day-21-27.initial" + case .vpnBreakageReport: + return "m_mac_vpn_breakage_report" + case .networkProtectionWaitlistUserActive: return "m_mac_netp_waitlist_user_active" case .networkProtectionWaitlistEntryPointMenuItemDisplayed: diff --git a/DuckDuckGo/Statistics/PixelParameters.swift b/DuckDuckGo/Statistics/PixelParameters.swift index 55f0f693ff..6fe2a8d67f 100644 --- a/DuckDuckGo/Statistics/PixelParameters.swift +++ b/DuckDuckGo/Statistics/PixelParameters.swift @@ -79,6 +79,13 @@ extension Pixel.Event { guard let trigger = triggerOrigin else { return nil } return [PixelKit.Parameters.dashboardTriggerOrigin: trigger] + case .vpnBreakageReport(let category, let description, let metadata): + return [ + PixelKit.Parameters.vpnBreakageCategory: category, + PixelKit.Parameters.vpnBreakageDescription: description, + PixelKit.Parameters.vpnBreakageMetadata: metadata + ] + // Don't use default to force new items to be thought about case .crash, .brokenSiteReport, diff --git a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift index 575effcf83..93200ad388 100644 --- a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift +++ b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift @@ -42,6 +42,8 @@ let nonSandboxedExtraInputFiles: Set = [ .init("DataBrokerProtectionFeatureDisabler.swift", .source), .init("DataBrokerProtectionAppEvents.swift", .source), .init("VPNMetadataCollector.swift", .source), + .init("VPNFeedbackCategory.swift", .source), + .init("VPNFeedbackSender.swift", .source), .init("DuckDuckGoDBPBackgroundAgent.app", .unknown) ] diff --git a/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift b/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift index 493df61c2d..8f2ca1bd8d 100644 --- a/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift +++ b/LocalPackages/PixelKit/Sources/PixelKit/PixelKit+Parameters.swift @@ -57,6 +57,11 @@ public extension PixelKit { // Dashboard public static let dashboardTriggerOrigin = "trigger_origin" + + // VPN + public static let vpnBreakageCategory = "breakageCategory" + public static let vpnBreakageDescription = "breakageDescription" + public static let vpnBreakageMetadata = "breakageMetadata" } enum Values { diff --git a/LocalPackages/SystemExtensionManager/Sources/SystemExtensionManager/SystemExtensionManager.swift b/LocalPackages/SystemExtensionManager/Sources/SystemExtensionManager/SystemExtensionManager.swift index 64d7ca5a62..956b79e3af 100644 --- a/LocalPackages/SystemExtensionManager/Sources/SystemExtensionManager/SystemExtensionManager.swift +++ b/LocalPackages/SystemExtensionManager/Sources/SystemExtensionManager/SystemExtensionManager.swift @@ -19,7 +19,7 @@ import Foundation import Cocoa import Combine -@preconcurrency import SystemExtensions +import SystemExtensions public enum SystemExtensionRequestError: Error { case unknownRequestResult From 448d6bf21e68ebfefb61e2ee36c1e3e6b26f8593 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Sun, 3 Dec 2023 20:20:46 -0800 Subject: [PATCH 08/34] Wire up pixel sending. --- .../VPNFeedbackSender.swift | 4 +- .../VPNMetadataCollector.swift | 99 ++++++++++++++----- 2 files changed, 79 insertions(+), 24 deletions(-) diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackSender.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackSender.swift index fe6a90bbdf..e382d7d17a 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackSender.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackSender.swift @@ -27,7 +27,9 @@ protocol VPNFeedbackSender { struct DefaultVPNFeedbackSender: VPNFeedbackSender { func send(metadata: VPNMetadata, category: VPNFeedbackCategory, userText: String) async throws { - print("[VPN] Sending metadata: \(metadata.toPrettyPrintedJSON() ?? "ERROR")") + let encodedUserText = userText.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? userText + let pixelEvent = Pixel.Event.vpnBreakageReport(category: category.rawValue, description: encodedUserText, metadata: metadata.toBase64()) + Pixel.fire(pixelEvent) } } diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector.swift index fcfdf297ce..192f9722a6 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector.swift @@ -20,6 +20,7 @@ import Foundation import Common +import LoginItems import NetworkProtection import NetworkProtectionIPC import NetworkProtectionUI @@ -44,16 +45,23 @@ struct VPNMetadata: Encodable { struct VPNState: Encodable { let onboardingState: String - let vpnIsEnabled: Bool + let connectionState: String + let serverLocation: String + } + + struct LoginItemState: Encodable { + let vpnMenuState: String + +#if NETP_SYSTEM_EXTENSION + let notificationsAgentState: String +#endif } let appInfo: AppInfo let deviceInfo: DeviceInfo let networkInfo: NetworkInfo let vpnState: VPNState - - // TODO: Agent status - // TODO: VPN configuration status + let loginItemState: LoginItemState func toPrettyPrintedJSON() -> String? { let encoder = JSONEncoder() @@ -68,7 +76,14 @@ struct VPNMetadata: Encodable { } func toBase64() -> String { - fatalError() + 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)" + } } } @@ -77,7 +92,31 @@ protocol VPNMetadataCollector { func collectMetadata() async -> VPNMetadata } -struct DefaultVPNMetadataCollector: VPNMetadataCollector { +final class DefaultVPNMetadataCollector: VPNMetadataCollector { + + private let statusReporter: NetworkProtectionStatusReporter + + init() { + let machServiceName = Bundle.main.vpnMenuAgentBundleId + let ipcClient = TunnelControllerIPCClient(machServiceName: machServiceName) + ipcClient.register() + + self.statusReporter = DefaultNetworkProtectionStatusReporter( + statusObserver: ipcClient.connectionStatusObserver, + serverInfoObserver: ipcClient.serverInfoObserver, + connectionErrorObserver: ipcClient.connectionErrorObserver, + connectivityIssuesObserver: ConnectivityIssueObserverThroughDistributedNotifications(), + controllerErrorMessageObserver: ControllerErrorMesssageObserverThroughDistributedNotifications() + ) + + // Force refresh just in case. A refresh is requested when the IPC client is created, but distributed notifications don't guarantee delivery + // so we'll play it safe and add one more attempt. + self.statusReporter.forceRefresh() + } + + deinit { + // print("DEINIT VPN METADATA COLLECTOR") + } @MainActor func collectMetadata() async -> VPNMetadata { @@ -85,12 +124,14 @@ struct DefaultVPNMetadataCollector: VPNMetadataCollector { let deviceInfoMetadata = collectDeviceInfoMetadata() let networkInfoMetadata = await collectNetworkInformation() let vpnState = await collectVPNState() + let loginItemState = collectLoginItemState() return VPNMetadata( appInfo: appInfoMetadata, deviceInfo: deviceInfoMetadata, networkInfo: networkInfoMetadata, - vpnState: vpnState + vpnState: vpnState, + loginItemState: loginItemState ) } @@ -126,11 +167,23 @@ struct DefaultVPNMetadataCollector: VPNMetadataCollector { func collectNetworkInformation() async -> VPNMetadata.NetworkInfo { let monitor = NWPathMonitor() monitor.start(queue: DispatchQueue(label: "VPNMetadataCollector.NWPathMonitor.paths")) - try? await Task.sleep(interval: .seconds(1)) - let path = monitor.currentPath - monitor.cancel() - return .init(currentPath: path.debugDescription) + var path: NWPath? + let startTime = CFAbsoluteTimeGetCurrent() + + while true { + if !monitor.currentPath.availableInterfaces.isEmpty { + path = monitor.currentPath + monitor.cancel() + return .init(currentPath: path.debugDescription) + } + + // Wait up to 3 seconds to fetch the path. + let currentExecutionTime = CFAbsoluteTimeGetCurrent() - startTime + if currentExecutionTime >= 3.0 { + return .init(currentPath: "Timed out fetching path") + } + } } @MainActor @@ -149,20 +202,20 @@ struct DefaultVPNMetadataCollector: VPNMetadataCollector { } } - let machServiceName = Bundle.main.vpnMenuAgentBundleId - let ipcClient = TunnelControllerIPCClient(machServiceName: machServiceName) - let controller = NetworkProtectionIPCTunnelController(ipcClient: ipcClient) // TODO: Get correct isConnected value - let statusReporter = DefaultNetworkProtectionStatusReporter( - statusObserver: ipcClient.connectionStatusObserver, - serverInfoObserver: ipcClient.serverInfoObserver, - connectionErrorObserver: ipcClient.connectionErrorObserver, - connectivityIssuesObserver: ConnectivityIssueObserverThroughDistributedNotifications(), - controllerErrorMessageObserver: ControllerErrorMesssageObserverThroughDistributedNotifications() - ) + let connectionState = String(describing: statusReporter.statusObserver.recentValue) + let serverLocation = statusReporter.serverInfoObserver.recentValue.serverLocation ?? "No Location" + return .init(onboardingState: onboardingState, connectionState: connectionState, serverLocation: serverLocation) + } - try? await Task.sleep(interval: .seconds(1)) + func collectLoginItemState() -> VPNMetadata.LoginItemState { + let vpnMenuState = String(describing: LoginItem.vpnMenu.status) - return .init(onboardingState: onboardingState, vpnIsEnabled: false) +#if NETP_SYSTEM_EXTENSION + let notificationsAgentState = String(describing: LoginItem.notificationsAgent.status) + return .init(vpnMenuState: vpnMenuState, notificationsAgentState: notificationsAgentState) +#else + return .init(vpnMenuState: vpnMenuState) +#endif } } From 95461ac2b835cbd6a207b4a5162d2c3356c95d31 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Sun, 3 Dec 2023 20:36:50 -0800 Subject: [PATCH 09/34] Use Swift Concurrency for sending pixels. --- .../VPNFeedbackSender.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackSender.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackSender.swift index e382d7d17a..6d253242e3 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackSender.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackSender.swift @@ -29,7 +29,18 @@ struct DefaultVPNFeedbackSender: VPNFeedbackSender { func send(metadata: VPNMetadata, category: VPNFeedbackCategory, userText: String) async throws { let encodedUserText = userText.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? userText let pixelEvent = Pixel.Event.vpnBreakageReport(category: category.rawValue, description: encodedUserText, metadata: metadata.toBase64()) - Pixel.fire(pixelEvent) + + print("[VPN] Sending breakage pixel, with metadata: \(metadata.toPrettyPrintedJSON()!)") + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + Pixel.fire(pixelEvent) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } } } From dfa675245fe862b8411216e506266e714c11bc81 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Mon, 4 Dec 2023 13:32:39 -0800 Subject: [PATCH 10/34] Add a sending failed state. --- .../NetworkProtectionDebugMenu.swift | 2 +- .../FeedbackFormView.swift | 17 ++++++++++++++--- .../FeedbackFormViewController.swift | 5 +++-- .../VPNFeedbackCategory.swift | 5 ++++- .../VPNFeedbackFormViewModel.swift | 2 +- 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift index ef9537940c..429bcbee64 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift @@ -247,7 +247,7 @@ final class NetworkProtectionDebugMenu: NSMenu { } parentWindowController.window?.beginSheet(feedbackFormWindow) { [weak self] _ in - print("DEBUG: Form closed") + // do something? } } diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift index 093b892949..cb30e7e570 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift @@ -73,6 +73,12 @@ struct FeedbackFormView: View { } } ) + + if viewModel.viewState == .feedbackSendingFailed { + Text("We couldn't send your feedback right now, please try again.") + .foregroundColor(.red) + .padding(.top, 15) + } case .feedbackSent: VPNFeedbackFormSentView() .padding([.top, .leading, .trailing], 20) @@ -119,6 +125,8 @@ private struct VPNFeedbackFormBodyView: View { case .landingPage: Spacer() .frame(height: 50) + case .unableToInstall: + VPNFeedbackFormIssueDescriptionForm() case .failsToConnect: VPNFeedbackFormIssueDescriptionForm() case .tooSlow: @@ -160,17 +168,20 @@ private struct VPNFeedbackFormIssueDescriptionForm: View { .multilineTextAlignment(.leading) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.secondary) VStack(alignment: .leading) { - Text("• Bullet one") - Text("• Bullet two") - Text("• Bullet three") + Text("• Whether specific DuckDuckGo features are enabled") + .foregroundColor(.secondary) + Text("• Aggregate DuckDuckGo app diagnostics") + .foregroundColor(.secondary) } Text("By clicking \"Submit\" I agree that DuckDuckGo may use the information in this report for purposes of improving the app's features.") .multilineTextAlignment(.leading) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.secondary) } } diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift index 30d2af0769..31296a4340 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift @@ -26,8 +26,9 @@ final class FeedbackFormViewController: NSViewController { enum Constants { static let landingPageHeight = 260.0 - static let feedbackFormHeight = 550.0 + static let feedbackFormHeight = 535.0 static let feedbackSentHeight = 340.0 + static let feedbackErrorHeight = 560.0 } private let defaultSize = CGSize(width: 480, height: 348) @@ -86,7 +87,7 @@ final class FeedbackFormViewController: NSViewController { case .feedbackSent: heightConstraint?.constant = Constants.feedbackSentHeight case .feedbackSendingFailed: - heightConstraint?.constant = Constants.feedbackFormHeight + heightConstraint?.constant = Constants.feedbackErrorHeight } } diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackCategory.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackCategory.swift index aedf9765d6..0d6f10cedc 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackCategory.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackCategory.swift @@ -22,6 +22,7 @@ import Foundation enum VPNFeedbackCategory: String, CaseIterable { case landingPage + case unableToInstall case failsToConnect case tooSlow case issueWithAppOrWebsite @@ -34,7 +35,8 @@ enum VPNFeedbackCategory: String, CaseIterable { switch self { case .landingPage: return false - case .failsToConnect, + case .unableToInstall, + .failsToConnect, .tooSlow, .issueWithAppOrWebsite, .cantConnectToLocalDevice, @@ -48,6 +50,7 @@ enum VPNFeedbackCategory: String, CaseIterable { var displayName: String { switch self { case .landingPage: return "What's happening?" + case .unableToInstall: return "Unable to install VPN" case .failsToConnect: return "VPN fails to connect" case .tooSlow: return "VPN is too slow" case .issueWithAppOrWebsite: return "Issue with app or website" diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift index b00f820d86..88a9b7c49c 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift +++ b/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift @@ -88,7 +88,7 @@ final class VPNFeedbackFormViewModel: ObservableObject { } private func updateSubmitButtonStatus() { - self.submitButtonEnabled = (viewState == .feedbackPending) && !feedbackFormText.isEmpty + self.submitButtonEnabled = (viewState == .feedbackPending || viewState == .feedbackSendingFailed) && !feedbackFormText.isEmpty } } From 9f3f1dfbaa0493c742c4034d8f60f4db3f65c9db Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Tue, 5 Dec 2023 15:29:09 -0800 Subject: [PATCH 11/34] Rename feedback form views and VCs. --- DuckDuckGo.xcodeproj/project.pbxproj | 38 +++++++++---------- .../NetworkProtectionDebugMenu.swift | 6 +-- .../VPNFeedbackCategory.swift | 0 .../VPNFeedbackFormView.swift} | 4 +- .../VPNFeedbackFormViewController.swift} | 8 ++-- .../VPNFeedbackFormViewModel.swift | 0 .../VPNFeedbackSender.swift | 0 .../VPNMetadataCollector.swift | 0 8 files changed, 27 insertions(+), 29 deletions(-) rename DuckDuckGo/{NetworkProtectionFeedbackForm => VPNFeedbackForm}/VPNFeedbackCategory.swift (100%) rename DuckDuckGo/{NetworkProtectionFeedbackForm/FeedbackFormView.swift => VPNFeedbackForm/VPNFeedbackFormView.swift} (99%) rename DuckDuckGo/{NetworkProtectionFeedbackForm/FeedbackFormViewController.swift => VPNFeedbackForm/VPNFeedbackFormViewController.swift} (92%) rename DuckDuckGo/{NetworkProtectionFeedbackForm => VPNFeedbackForm}/VPNFeedbackFormViewModel.swift (100%) rename DuckDuckGo/{NetworkProtectionFeedbackForm => VPNFeedbackForm}/VPNFeedbackSender.swift (100%) rename DuckDuckGo/{NetworkProtectionFeedbackForm => VPNFeedbackForm}/VPNMetadataCollector.swift (100%) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 22912137c4..dd98252fef 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1128,14 +1128,14 @@ 4B41EDA82B1543C9001EEDF4 /* PreferencesVPNView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDA62B1543C9001EEDF4 /* PreferencesVPNView.swift */; }; 4B41EDA92B1543C9001EEDF4 /* PreferencesVPNView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDA62B1543C9001EEDF4 /* PreferencesVPNView.swift */; }; 4B41EDAB2B1544B2001EEDF4 /* LoginItems in Frameworks */ = {isa = PBXBuildFile; productRef = 4B41EDAA2B1544B2001EEDF4 /* LoginItems */; }; - 4B41EDAE2B168AFF001EEDF4 /* FeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* FeedbackFormViewController.swift */; }; - 4B41EDAF2B168AFF001EEDF4 /* FeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* FeedbackFormViewController.swift */; }; - 4B41EDB12B168B1E001EEDF4 /* FeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */; }; - 4B41EDB22B168B1E001EEDF4 /* FeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */; }; + 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 */; }; - 4B41EDB62B169883001EEDF4 /* FeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* FeedbackFormViewController.swift */; }; - 4B41EDB72B169887001EEDF4 /* FeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */; }; + 4B41EDB62B169883001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDAD2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift */; }; + 4B41EDB72B169887001EEDF4 /* VPNFeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB02B168B1E001EEDF4 /* VPNFeedbackFormView.swift */; }; 4B41EDB82B169889001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */; }; 4B434690285ED7A100177407 /* BookmarksBarViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B43468F285ED7A100177407 /* BookmarksBarViewModelTests.swift */; }; 4B43469528655D1400177407 /* FirefoxDataImporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B43469428655D1400177407 /* FirefoxDataImporterTests.swift */; }; @@ -3356,8 +3356,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 /* FeedbackFormViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackFormViewController.swift; sourceTree = ""; }; - 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackFormView.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 = ""; }; 4B43468F285ED7A100177407 /* BookmarksBarViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksBarViewModelTests.swift; sourceTree = ""; }; 4B43469428655D1400177407 /* FirefoxDataImporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirefoxDataImporterTests.swift; sourceTree = ""; }; @@ -5055,17 +5055,17 @@ path = DeviceAuthentication; sourceTree = ""; }; - 4B41EDAC2B168A66001EEDF4 /* NetworkProtectionFeedbackForm */ = { + 4B41EDAC2B168A66001EEDF4 /* VPNFeedbackForm */ = { isa = PBXGroup; children = ( - 4B41EDAD2B168AFF001EEDF4 /* FeedbackFormViewController.swift */, - 4B41EDB02B168B1E001EEDF4 /* FeedbackFormView.swift */, + 4B41EDAD2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift */, + 4B41EDB02B168B1E001EEDF4 /* VPNFeedbackFormView.swift */, 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */, 4B0526632B1D55D80054955A /* VPNFeedbackCategory.swift */, 4B05265D2B1AE5C70054955A /* VPNMetadataCollector.swift */, 4B0526602B1D55320054955A /* VPNFeedbackSender.swift */, ); - path = NetworkProtectionFeedbackForm; + path = VPNFeedbackForm; sourceTree = ""; }; 4B43468D285ED6BD00177407 /* BookmarksBar */ = { @@ -6429,7 +6429,7 @@ AA97BF4425135CB60014931A /* Menus */, 85378D9A274E618C007C5CBF /* MessageViews */, AA86491524D83384001BABEE /* NavigationBar */, - 4B41EDAC2B168A66001EEDF4 /* NetworkProtectionFeedbackForm */, + 4B41EDAC2B168A66001EEDF4 /* VPNFeedbackForm */, 4B4D60542A0B29FA00BCD287 /* NetworkProtection */, 85B7184727677A7D00B4277F /* Onboarding */, 1D074B252909A371006E4AC3 /* PasswordManager */, @@ -9493,7 +9493,7 @@ 3706FBA4293F65D500E42796 /* ContentOverlayPopover.swift in Sources */, 3706FBA5293F65D500E42796 /* TabShadowView.swift in Sources */, 3706FBA7293F65D500E42796 /* EncryptedValueTransformer.swift in Sources */, - 4B41EDAF2B168AFF001EEDF4 /* FeedbackFormViewController.swift in Sources */, + 4B41EDAF2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */, 3706FBA8293F65D500E42796 /* PasteboardBookmark.swift in Sources */, 3706FBA9293F65D500E42796 /* PinnedTabsManager.swift in Sources */, B66260E829ACD0C900E9E3EE /* DuckPlayerTabExtension.swift in Sources */, @@ -9734,7 +9734,7 @@ 3706FC69293F65D500E42796 /* UserScripts.swift in Sources */, 3706FC6A293F65D500E42796 /* NSWorkspaceExtension.swift in Sources */, B6C0BB6829AEFF8100AE8E3C /* BookmarkExtension.swift in Sources */, - 4B41EDB22B168B1E001EEDF4 /* FeedbackFormView.swift in Sources */, + 4B41EDB22B168B1E001EEDF4 /* VPNFeedbackFormView.swift in Sources */, 3706FC6C293F65D500E42796 /* BookmarkViewModel.swift in Sources */, 3706FC6D293F65D500E42796 /* DaxSpeech.swift in Sources */, 3706FC6E293F65D500E42796 /* DuckPlayerSchemeHandler.swift in Sources */, @@ -10255,7 +10255,7 @@ 4B9579652AC7AE700062CA31 /* SecureVaultSorting.swift in Sources */, 4B9579662AC7AE700062CA31 /* PreferencesSidebarModel.swift in Sources */, 4B9579672AC7AE700062CA31 /* DuckPlayerURLExtension.swift in Sources */, - 4B41EDB72B169887001EEDF4 /* FeedbackFormView.swift in Sources */, + 4B41EDB72B169887001EEDF4 /* VPNFeedbackFormView.swift in Sources */, 4B9579682AC7AE700062CA31 /* BWEncryptionOutput.m in Sources */, 4B9579692AC7AE700062CA31 /* PermissionState.swift in Sources */, 4B95796A2AC7AE700062CA31 /* FeedbackPresenter.swift in Sources */, @@ -10340,7 +10340,7 @@ 4B9579B62AC7AE700062CA31 /* AtbParser.swift in Sources */, 4B9579B72AC7AE700062CA31 /* PreferencesDuckPlayerView.swift in Sources */, 4B9579B82AC7AE700062CA31 /* AddFolderModalViewController.swift in Sources */, - 4B41EDB62B169883001EEDF4 /* FeedbackFormViewController.swift in Sources */, + 4B41EDB62B169883001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */, 4B9579B92AC7AE700062CA31 /* BookmarkSidebarTreeController.swift in Sources */, 4B9579BA2AC7AE700062CA31 /* HomePageFavoritesModel.swift in Sources */, 4B9579BB2AC7AE700062CA31 /* SequenceExtensions.swift in Sources */, @@ -11029,7 +11029,7 @@ 4B9292D72667124000AD2C21 /* NSPopUpButtonExtension.swift in Sources */, 85D33F1225C82EB3002B91A6 /* ConfigurationManager.swift in Sources */, 31F28C4F28C8EEC500119F70 /* YoutubePlayerUserScript.swift in Sources */, - 4B41EDAE2B168AFF001EEDF4 /* FeedbackFormViewController.swift in Sources */, + 4B41EDAE2B168AFF001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */, B6A9E48426146AAB0067D1B9 /* PixelParameters.swift in Sources */, AA5FA697275F90C400DCE9C9 /* FaviconImageCache.swift in Sources */, 1430DFF524D0580F00B8978C /* TabBarViewController.swift in Sources */, @@ -11364,7 +11364,7 @@ 4B9292D52667123700AD2C21 /* BookmarkManagementDetailViewController.swift in Sources */, 4B723E1026B0006700E14D75 /* CSVImporter.swift in Sources */, 37A4CEBA282E992F00D75B89 /* StartupPreferences.swift in Sources */, - 4B41EDB12B168B1E001EEDF4 /* FeedbackFormView.swift in Sources */, + 4B41EDB12B168B1E001EEDF4 /* VPNFeedbackFormView.swift in Sources */, 7BFE95542A9DF2930081ABE9 /* UserDefaults+NetworkProtectionWaitlist.swift in Sources */, AA4BBA3B25C58FA200C4FB0F /* MainMenu.swift in Sources */, 4B8AC93326B3B06300879451 /* EdgeDataImporter.swift in Sources */, diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift index a8e324e9c7..09952c3c98 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift @@ -240,7 +240,7 @@ final class NetworkProtectionDebugMenu: NSMenu { } @objc func openNativeFeedbackForm(_ sender: Any?) { - let feedbackFormViewController = FeedbackFormViewController() + let feedbackFormViewController = VPNFeedbackFormViewController() let feedbackFormWindowController = feedbackFormViewController.wrappedInWindowController() guard let feedbackFormWindow = feedbackFormWindowController.window, @@ -249,9 +249,7 @@ final class NetworkProtectionDebugMenu: NSMenu { return } - parentWindowController.window?.beginSheet(feedbackFormWindow) { [weak self] _ in - // do something? - } + parentWindowController.window?.beginSheet(feedbackFormWindow) } /// Sets the selected server. diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackCategory.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackCategory.swift similarity index 100% rename from DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackCategory.swift rename to DuckDuckGo/VPNFeedbackForm/VPNFeedbackCategory.swift diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift similarity index 99% rename from DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift rename to DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index cb30e7e570..2b7dd68ee4 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -1,5 +1,5 @@ // -// FeedbackFormView.swift +// VPNFeedbackFormView.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -21,7 +21,7 @@ import SwiftUI #if NETWORK_PROTECTION -struct FeedbackFormView: View { +struct VPNFeedbackFormView: View { struct ViewSize { fileprivate(set) var headerHeight: Double = 0.0 diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift similarity index 92% rename from DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift rename to DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift index 31296a4340..c06698e395 100644 --- a/DuckDuckGo/NetworkProtectionFeedbackForm/FeedbackFormViewController.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift @@ -1,5 +1,5 @@ // -// FeedbackFormViewController.swift +// VPNFeedbackFormViewController.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -22,7 +22,7 @@ import Foundation import AppKit import SwiftUI -final class FeedbackFormViewController: NSViewController { +final class VPNFeedbackFormViewController: NSViewController { enum Constants { static let landingPageHeight = 260.0 @@ -53,7 +53,7 @@ final class FeedbackFormViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() - let feedbackFormView = FeedbackFormView { newHeight in + let feedbackFormView = VPNFeedbackFormView { newHeight in self.updateViewHeight(height: newHeight) } @@ -93,7 +93,7 @@ final class FeedbackFormViewController: NSViewController { } -extension FeedbackFormViewController: VPNFeedbackFormViewModelDelegate { +extension VPNFeedbackFormViewController: VPNFeedbackFormViewModelDelegate { func vpnFeedbackViewModelDismissedView(_ viewModel: VPNFeedbackFormViewModel) { dismiss() diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift similarity index 100% rename from DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackFormViewModel.swift rename to DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackSender.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackSender.swift similarity index 100% rename from DuckDuckGo/NetworkProtectionFeedbackForm/VPNFeedbackSender.swift rename to DuckDuckGo/VPNFeedbackForm/VPNFeedbackSender.swift diff --git a/DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector.swift b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift similarity index 100% rename from DuckDuckGo/NetworkProtectionFeedbackForm/VPNMetadataCollector.swift rename to DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift From 6b2c8804d6efbf09bef21d6b9ad902865a4478f6 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Tue, 5 Dec 2023 15:48:41 -0800 Subject: [PATCH 12/34] Clean up height calculation. --- .../VPNFeedbackForm/VPNFeedbackFormView.swift | 46 ------------------- .../VPNFeedbackFormViewController.swift | 32 ++++++++++--- 2 files changed, 26 insertions(+), 52 deletions(-) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index 2b7dd68ee4..89434da969 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -23,26 +23,8 @@ import SwiftUI struct VPNFeedbackFormView: View { - struct ViewSize { - fileprivate(set) var headerHeight: Double = 0.0 - fileprivate(set) var viewHeight: Double = 0.0 - fileprivate(set) var buttonsHeight: Double = 0.0 - - var totalHeight: Double { - return headerHeight + viewHeight + buttonsHeight + 80 - } - } - @EnvironmentObject var viewModel: VPNFeedbackFormViewModel - let sizeChanged: (CGFloat) -> Void - - @State var viewSize: ViewSize = .init() { - didSet { - sizeChanged(viewSize.totalHeight) - } - } - var body: some View { VStack(spacing: 0) { Group { @@ -52,13 +34,6 @@ struct VPNFeedbackFormView: View { .frame(height: 70) .frame(maxWidth: .infinity) .background(Color.secondary.opacity(0.1)) - .background( - GeometryReader { proxy in - Color.clear.onAppear { - viewSize.headerHeight = proxy.size.height - } - } - ) Divider() @@ -66,13 +41,6 @@ struct VPNFeedbackFormView: View { case .feedbackPending, .feedbackSending, .feedbackSendingFailed: VPNFeedbackFormBodyView() .padding([.top, .leading, .trailing], 20) - .background( - GeometryReader { proxy in - Color.clear.onAppear { - viewSize.viewHeight = proxy.size.height - } - } - ) if viewModel.viewState == .feedbackSendingFailed { Text("We couldn't send your feedback right now, please try again.") @@ -82,26 +50,12 @@ struct VPNFeedbackFormView: View { case .feedbackSent: VPNFeedbackFormSentView() .padding([.top, .leading, .trailing], 20) - .background( - GeometryReader { proxy in - Color.clear.onAppear { - viewSize.viewHeight = proxy.size.height - } - } - ) } Spacer(minLength: 0) VPNFeedbackFormButtons() .padding(20) - .background( - GeometryReader { proxy in - Color.clear.onAppear { - viewSize.buttonsHeight = proxy.size.height - } - } - ) } } diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift index c06698e395..cf1630cf07 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift @@ -21,9 +21,13 @@ import Foundation import AppKit import SwiftUI +import Combine final class VPNFeedbackFormViewController: 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 feedbackFormHeight = 535.0 @@ -31,10 +35,11 @@ final class VPNFeedbackFormViewController: NSViewController { static let feedbackErrorHeight = 560.0 } - private let defaultSize = CGSize(width: 480, height: 348) + private let defaultSize = CGSize(width: 480, height: Constants.landingPageHeight) private let viewModel: VPNFeedbackFormViewModel private var heightConstraint: NSLayoutConstraint? + private var cancellables = Set() init() { self.viewModel = VPNFeedbackFormViewModel() @@ -53,10 +58,7 @@ final class VPNFeedbackFormViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() - let feedbackFormView = VPNFeedbackFormView { newHeight in - self.updateViewHeight(height: newHeight) - } - + let feedbackFormView = VPNFeedbackFormView() let hostingView = NSHostingView(rootView: feedbackFormView.environmentObject(self.viewModel)) hostingView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(hostingView) @@ -72,9 +74,27 @@ final class VPNFeedbackFormViewController: NSViewController { 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.$selectedFeedbackCategory + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateViewHeight() + } + .store(in: &cancellables) } - private func updateViewHeight(height: CGFloat) { + private func updateViewHeight() { switch viewModel.viewState { case .feedbackPending: if viewModel.selectedFeedbackCategory == .landingPage { From e1ff59db61f407db466fd230ba5bb7d2219680ee Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Tue, 5 Dec 2023 15:52:19 -0800 Subject: [PATCH 13/34] More code clean-up. --- .../VPNFeedbackForm/VPNFeedbackFormView.swift | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index 89434da969..dfff7fb268 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -79,21 +79,14 @@ private struct VPNFeedbackFormBodyView: View { case .landingPage: Spacer() .frame(height: 50) - case .unableToInstall: - VPNFeedbackFormIssueDescriptionForm() - case .failsToConnect: - VPNFeedbackFormIssueDescriptionForm() - case .tooSlow: - VPNFeedbackFormIssueDescriptionForm() - case .issueWithAppOrWebsite: - VPNFeedbackFormIssueDescriptionForm() - case .cantConnectToLocalDevice: - VPNFeedbackFormIssueDescriptionForm() - case .appCrashesOrFreezes: - VPNFeedbackFormIssueDescriptionForm() - case .featureRequest: - VPNFeedbackFormIssueDescriptionForm() - case .somethingElse: + case .unableToInstall, + .failsToConnect, + .tooSlow, + .issueWithAppOrWebsite, + .cantConnectToLocalDevice, + .appCrashesOrFreezes, + .featureRequest, + .somethingElse: VPNFeedbackFormIssueDescriptionForm() } } From b63dfcf8422a37b11c6534726fd52ba74e1c2661 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Tue, 5 Dec 2023 15:56:23 -0800 Subject: [PATCH 14/34] De-duplicate FocusableTextEditor. --- DuckDuckGo.xcodeproj/project.pbxproj | 8 +++ .../View/SwiftUI/FocusableTextEditor.swift | 53 +++++++++++++++++++ .../PasswordManagementLoginItemView.swift | 39 +------------- .../VPNFeedbackForm/VPNFeedbackFormView.swift | 34 ------------ 4 files changed, 63 insertions(+), 71 deletions(-) create mode 100644 DuckDuckGo/Common/View/SwiftUI/FocusableTextEditor.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index dd98252fef..ea9f4c8d89 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1139,6 +1139,9 @@ 4B41EDB82B169889001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.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 */; }; + 4B44FEF42B1FEF5A000619D8 /* FocusableTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B44FEF22B1FEF5A000619D8 /* FocusableTextEditor.swift */; }; + 4B44FEF52B1FEF5A000619D8 /* FocusableTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B44FEF22B1FEF5A000619D8 /* FocusableTextEditor.swift */; }; 4B4BEC3D2A11B56B001D9AC5 /* DuckDuckGoNotificationsAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4BEC382A11B509001D9AC5 /* DuckDuckGoNotificationsAppDelegate.swift */; }; 4B4BEC3E2A11B56E001D9AC5 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4BEC322A11B509001D9AC5 /* Logging.swift */; }; 4B4BEC402A11B5B5001D9AC5 /* NetworkProtectionExtensionMachService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4D60782A0B29FA00BCD287 /* NetworkProtectionExtensionMachService.swift */; }; @@ -3361,6 +3364,7 @@ 4B41EDB32B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewModel.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 = ""; }; 4B4BEC182A11B3EA001D9AC5 /* DuckDuckGoNotifications.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DuckDuckGoNotifications.xcconfig; sourceTree = ""; }; 4B4BEC202A11B4E2001D9AC5 /* DuckDuckGo Notifications.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "DuckDuckGo Notifications.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 4B4BEC322A11B509001D9AC5 /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; @@ -5984,6 +5988,7 @@ 31C3CE0128EDC1E70002C24A /* CustomRoundedCornersShape.swift */, 378F44EA29B4C73E00899924 /* View+RoundedCorners.swift */, B690152B2ACBF4DA00AD0BAB /* MenuPreview.swift */, + 4B44FEF22B1FEF5A000619D8 /* FocusableTextEditor.swift */, ); path = SwiftUI; sourceTree = ""; @@ -9174,6 +9179,7 @@ 3706FAA1293F65D500E42796 /* NSAlert+DataImport.swift in Sources */, 3706FAA2293F65D500E42796 /* MainWindow.swift in Sources */, 3707C727294B5D2900682A9F /* WKWebView+SessionState.swift in Sources */, + 4B44FEF42B1FEF5A000619D8 /* FocusableTextEditor.swift in Sources */, 3706FAA3293F65D500E42796 /* CrashReportPromptViewController.swift in Sources */, 3706FAA4293F65D500E42796 /* ContextMenuManager.swift in Sources */, 3706FAA5293F65D500E42796 /* GradientView.swift in Sources */, @@ -10512,6 +10518,7 @@ 1E2AE4C82ACB216B00684E0A /* HoverTrackingArea.swift in Sources */, 4B957A582AC7AE700062CA31 /* PasswordManagementIdentityItemView.swift in Sources */, 4B957A592AC7AE700062CA31 /* ProgressExtension.swift in Sources */, + 4B44FEF52B1FEF5A000619D8 /* FocusableTextEditor.swift in Sources */, 4B957A5A2AC7AE700062CA31 /* CSVParser.swift in Sources */, 4B957A5B2AC7AE700062CA31 /* PixelDataModel.xcdatamodeld in Sources */, 4B957A5C2AC7AE700062CA31 /* PrivacyDashboardWebView.swift in Sources */, @@ -11349,6 +11356,7 @@ 569277C129DDCBB500B633EF /* HomePageContinueSetUpModel.swift in Sources */, B68D21CF2ACBC9FC002DA3C2 /* ContentBlockerRulesManagerMock.swift in Sources */, B6A924D92664C72E001A28CA /* WebKitDownloadTask.swift in Sources */, + 4B44FEF32B1FEF5A000619D8 /* FocusableTextEditor.swift in Sources */, 4B59023E26B35F3600489384 /* ChromiumLoginReader.swift in Sources */, 85D885B326A5A9DE0077C374 /* NSAlert+PasswordManager.swift in Sources */, 983DFB2528B67036006B7E34 /* UserContentUpdating.swift in Sources */, diff --git a/DuckDuckGo/Common/View/SwiftUI/FocusableTextEditor.swift b/DuckDuckGo/Common/View/SwiftUI/FocusableTextEditor.swift new file mode 100644 index 0000000000..3dcd13b55d --- /dev/null +++ b/DuckDuckGo/Common/View/SwiftUI/FocusableTextEditor.swift @@ -0,0 +1,53 @@ +// +// FocusableTextEditor.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 SwiftUI + +@available(macOS 12, *) +struct FocusableTextEditor: View { + + @Binding var text: String + @FocusState var isFocused: Bool + + let cornerRadius: CGFloat = 8.0 + let borderWidth: CGFloat = 0.4 + let characterLimit: Int = 10000 + + var body: some View { + TextEditor(text: $text) + .frame(height: 150.0) + .font(.body) + .foregroundColor(.primary) + .focused($isFocused) + .padding(EdgeInsets(top: 3.0, leading: 6.0, bottom: 5.0, trailing: 0.0)) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .onChange(of: text) { + text = String($0.prefix(characterLimit)) + } + .background( + ZStack { + RoundedRectangle(cornerRadius: cornerRadius).stroke(Color.accentColor.opacity(0.5), lineWidth: 4).opacity(isFocused ? 1 : 0).scaleEffect(isFocused ? 1 : 1.04) + .animation(isFocused ? .easeIn(duration: 0.2) : .easeOut(duration: 0.0), value: isFocused) + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color(NSColor.textEditorBorderColor), lineWidth: borderWidth) + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color(NSColor.textEditorBackgroundColor)) + } + ) + } +} diff --git a/DuckDuckGo/SecureVault/View/PasswordManagementLoginItemView.swift b/DuckDuckGo/SecureVault/View/PasswordManagementLoginItemView.swift index eb28665527..65b41fc85a 100644 --- a/DuckDuckGo/SecureVault/View/PasswordManagementLoginItemView.swift +++ b/DuckDuckGo/SecureVault/View/PasswordManagementLoginItemView.swift @@ -470,10 +470,10 @@ private struct NotesView: View { if model.isEditing || model.isNew { #if APPSTORE - FocusableTextEditor() + FocusableTextEditor(text: $model.notes) #else if #available(macOS 12, *) { - FocusableTextEditor() + FocusableTextEditor(text: $model.notes) } else { TextEditor(text: $model.notes) .frame(height: 197.0) @@ -509,41 +509,6 @@ private struct NotesView: View { } -@available(macOS 12, *) -private struct FocusableTextEditor: View { - - @EnvironmentObject var model: PasswordManagementLoginModel - @FocusState var isFocused: Bool - - let cornerRadius: CGFloat = 8.0 - let borderWidth: CGFloat = 0.4 - let characterLimit: Int = 10000 - - var body: some View { - TextEditor(text: $model.notes) - .frame(height: 197.0) - .font(.body) - .foregroundColor(.primary) - .focused($isFocused) - .padding(EdgeInsets(top: 3.0, leading: 6.0, bottom: 5.0, trailing: 0.0)) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, - style: .continuous)) - .onChange(of: model.notes) { - model.notes = String($0.prefix(characterLimit)) - } - .background( - ZStack { - RoundedRectangle(cornerRadius: cornerRadius).stroke(Color.accentColor.opacity(0.5), lineWidth: 4).opacity(isFocused ? 1 : 0).scaleEffect(isFocused ? 1 : 1.04) - .animation(isFocused ? .easeIn(duration: 0.2) : .easeOut(duration: 0.0), value: isFocused) - RoundedRectangle(cornerRadius: cornerRadius) - .stroke(Color(NSColor.textEditorBorderColor), lineWidth: borderWidth) - RoundedRectangle(cornerRadius: cornerRadius) - .fill(Color(NSColor.textEditorBackgroundColor)) - } - ) - } -} - private struct DatesView: View { @EnvironmentObject var model: PasswordManagementLoginModel diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index dfff7fb268..6abe3ca57a 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -154,40 +154,6 @@ private struct VPNFeedbackFormSentView: View { } -@available(macOS 12, *) -private struct FocusableTextEditor: View { - - @Binding var text: String - @FocusState var isFocused: Bool - - let cornerRadius: CGFloat = 8.0 - let borderWidth: CGFloat = 0.4 - let characterLimit: Int = 10000 - - var body: some View { - TextEditor(text: $text) - .frame(height: 150.0) - .font(.body) - .foregroundColor(.primary) - .focused($isFocused) - .padding(EdgeInsets(top: 3.0, leading: 6.0, bottom: 5.0, trailing: 0.0)) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - .onChange(of: text) { - text = String($0.prefix(characterLimit)) - } - .background( - ZStack { - RoundedRectangle(cornerRadius: cornerRadius).stroke(Color.accentColor.opacity(0.5), lineWidth: 4).opacity(isFocused ? 1 : 0).scaleEffect(isFocused ? 1 : 1.04) - .animation(isFocused ? .easeIn(duration: 0.2) : .easeOut(duration: 0.0), value: isFocused) - RoundedRectangle(cornerRadius: cornerRadius) - .stroke(Color(NSColor.textEditorBorderColor), lineWidth: borderWidth) - RoundedRectangle(cornerRadius: cornerRadius) - .fill(Color(NSColor.textEditorBackgroundColor)) - } - ) - } -} - private struct VPNFeedbackFormButtons: View { @EnvironmentObject var viewModel: VPNFeedbackFormViewModel From d612f2f708a048e5734ed48e4235f5ed8e620235 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Tue, 5 Dec 2023 16:00:27 -0800 Subject: [PATCH 15/34] Update a comment. --- DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index 6abe3ca57a..4845dd92b1 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -108,7 +108,7 @@ private struct VPNFeedbackFormIssueDescriptionForm: View { if #available(macOS 12, *) { FocusableTextEditor(text: $viewModel.feedbackFormText) } else { - // TODO: Add macOS 11 editor + // TODO: Add macOS 11 support. Using the approach from Autofill is causing obscure compilation errors here. } Text("In addition to the details entered into this form, your app issue report will contain:") From ce87c6a328c7531347dea77bab419a0fe0144e0f Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Tue, 5 Dec 2023 16:05:51 -0800 Subject: [PATCH 16/34] Somewhat fix a todo. --- .../VPNFeedbackForm/VPNFeedbackFormView.swift | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index 4845dd92b1..9562426ea4 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -105,11 +105,7 @@ private struct VPNFeedbackFormIssueDescriptionForm: View { .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) - if #available(macOS 12, *) { - FocusableTextEditor(text: $viewModel.feedbackFormText) - } else { - // TODO: Add macOS 11 support. Using the approach from Autofill is causing obscure compilation errors here. - } + textEditor() Text("In addition to the details entered into this form, your app issue report will contain:") .multilineTextAlignment(.leading) @@ -132,6 +128,36 @@ private struct VPNFeedbackFormIssueDescriptionForm: View { } } + @ViewBuilder + func textEditor() -> some View { +#if APPSTORE + FocusableTextEditor(text: $model.notes) +#else + if #available(macOS 12, *) { + FocusableTextEditor(text: $viewModel.feedbackFormText) + } else { + TextEditor(text: $viewModel.feedbackFormText) + .frame(height: 197.0) + .font(.body) + .foregroundColor(.primary) + .onChange(of: viewModel.feedbackFormText) { + viewModel.feedbackFormText = String($0.prefix(10000)) + } + .padding(EdgeInsets(top: 3.0, leading: 6.0, bottom: 5.0, trailing: 0.0)) + // .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + // .background( + // ZStack { + // RoundedRectangle(cornerRadius: cornerRadius) + // .stroke(Color(NSColor.textEditorBorderColor), lineWidth: 0.4) + // RoundedRectangle(cornerRadius: cornerRadius) + // .fill(Color(NSColor.textEditorBackgroundColor)) + // } + // ) + // ^ the code above is failing to compile with "failed to produce diagnostic for expression", need to debug it further. + } +#endif + } + } private struct VPNFeedbackFormSentView: View { From 370cfa805caa99eeb423d6ca0b67b40ea8041f4f Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Tue, 5 Dec 2023 16:12:36 -0800 Subject: [PATCH 17/34] Wire the feedback form up to the real Share Feedback button. This needs to handle the case where the app is open but no windows are available. --- DuckDuckGo/Application/URLEventHandler.swift | 2 ++ .../AppLauncher.swift | 2 +- .../Windows/View/WindowControllersManager.swift | 13 +++++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/Application/URLEventHandler.swift b/DuckDuckGo/Application/URLEventHandler.swift index f777ec90cd..a50c2101c4 100644 --- a/DuckDuckGo/Application/URLEventHandler.swift +++ b/DuckDuckGo/Application/URLEventHandler.swift @@ -123,6 +123,8 @@ final class URLEventHandler { } case AppLaunchCommand.showSettings.launchURL: WindowControllersManager.shared.showPreferencesTab(withSelectedPane: .vpn) + case AppLaunchCommand.shareFeedback.launchURL: + WindowControllersManager.shared.showShareFeedbackModal() default: return } diff --git a/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/AppLauncher.swift b/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/AppLauncher.swift index 2f072731dd..627ff52d49 100644 --- a/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/AppLauncher.swift +++ b/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/AppLauncher.swift @@ -83,7 +83,7 @@ extension AppLaunchCommand { case .justOpen: return "networkprotection://just-open" case .shareFeedback: - return "https://form.asana.com/?k=_wNLt6YcT5ILpQjDuW0Mxw&d=137249556945" + return "networkprotection://share-feedback" case .showStatus: return "networkprotection://show-status" case .showSettings: diff --git a/DuckDuckGo/Windows/View/WindowControllersManager.swift b/DuckDuckGo/Windows/View/WindowControllersManager.swift index 5a2beeadfe..4287d002ef 100644 --- a/DuckDuckGo/Windows/View/WindowControllersManager.swift +++ b/DuckDuckGo/Windows/View/WindowControllersManager.swift @@ -219,6 +219,19 @@ extension WindowControllersManager { windowController.mainViewController.navigationBarViewController.showNetworkProtectionStatus() } + + func showShareFeedbackModal() { + let feedbackFormViewController = VPNFeedbackFormViewController() + let feedbackFormWindowController = feedbackFormViewController.wrappedInWindowController() + + guard let feedbackFormWindow = feedbackFormWindowController.window, + let parentWindowController = WindowControllersManager.shared.lastKeyMainWindowController else { + assertionFailure("Failed to present native VPN feedback form") + return + } + + parentWindowController.window?.beginSheet(feedbackFormWindow) + } #endif } From 3466486eac702544d561c918969909600682596c Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Tue, 5 Dec 2023 16:35:50 -0800 Subject: [PATCH 18/34] Remove the feedback form from the debug menu. --- .../NetworkProtectionDebugMenu.swift | 16 ---------------- .../VPNFeedbackForm/VPNMetadataCollector.swift | 2 +- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift index 09952c3c98..7467acaa2f 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift @@ -95,9 +95,6 @@ final class NetworkProtectionDebugMenu: NSMenu { NSMenuItem(title: "Send Test Notification", action: #selector(NetworkProtectionDebugMenu.sendTestNotification)) .targetting(self) - NSMenuItem(title: "Open Native Feedback Form", action: #selector(NetworkProtectionDebugMenu.openNativeFeedbackForm)) - .targetting(self) - NSMenuItem(title: "Onboarding") .submenu(NetworkProtectionOnboardingMenu()) @@ -239,19 +236,6 @@ final class NetworkProtectionDebugMenu: NSMenu { } } - @objc func openNativeFeedbackForm(_ sender: Any?) { - let feedbackFormViewController = VPNFeedbackFormViewController() - let feedbackFormWindowController = feedbackFormViewController.wrappedInWindowController() - - guard let feedbackFormWindow = feedbackFormWindowController.window, - let parentWindowController = WindowControllersManager.shared.lastKeyMainWindowController else { - assertionFailure("Failed to present native VPN feedback form") - return - } - - parentWindowController.window?.beginSheet(feedbackFormWindow) - } - /// Sets the selected server. /// @objc func setSelectedServer(_ menuItem: NSMenuItem) { diff --git a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift index 192f9722a6..230f56d406 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift @@ -115,7 +115,7 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { } deinit { - // print("DEINIT VPN METADATA COLLECTOR") + print("DEINIT VPN METADATA COLLECTOR") } @MainActor From b9d6c2bf25b1178205eb32db5ccb86128a3804cb Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Tue, 5 Dec 2023 16:36:42 -0800 Subject: [PATCH 19/34] Remove print statements. --- DuckDuckGo/VPNFeedbackForm/VPNFeedbackSender.swift | 2 -- DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift | 4 ---- 2 files changed, 6 deletions(-) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackSender.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackSender.swift index 6d253242e3..dcdbfd0d96 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackSender.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackSender.swift @@ -30,8 +30,6 @@ struct DefaultVPNFeedbackSender: VPNFeedbackSender { let encodedUserText = userText.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? userText let pixelEvent = Pixel.Event.vpnBreakageReport(category: category.rawValue, description: encodedUserText, metadata: metadata.toBase64()) - print("[VPN] Sending breakage pixel, with metadata: \(metadata.toPrettyPrintedJSON()!)") - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in Pixel.fire(pixelEvent) { error in if let error { diff --git a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift index 230f56d406..6c4da29223 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift @@ -114,10 +114,6 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { self.statusReporter.forceRefresh() } - deinit { - print("DEINIT VPN METADATA COLLECTOR") - } - @MainActor func collectMetadata() async -> VPNMetadata { let appInfoMetadata = collectAppInfoMetadata() From ee9601c8183eaf6d92c6eb5b956c89c425e2bb02 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Tue, 5 Dec 2023 19:41:58 -0800 Subject: [PATCH 20/34] Use the correct asset for the form. --- .../VPNFeedbackSent.imageset/Contents.json | 12 ++++++++++++ .../VPNFeedbackSent.imageset/VPNFeedbackSent.pdf | Bin 0 -> 5591 bytes .../VPNFeedbackForm/VPNFeedbackCategory.swift | 6 +++--- .../VPNFeedbackForm/VPNFeedbackFormView.swift | 3 ++- .../VPNFeedbackFormViewController.swift | 2 +- 5 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 DuckDuckGo/Assets.xcassets/Images/VPNFeedbackSent.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Images/VPNFeedbackSent.imageset/VPNFeedbackSent.pdf diff --git a/DuckDuckGo/Assets.xcassets/Images/VPNFeedbackSent.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/VPNFeedbackSent.imageset/Contents.json new file mode 100644 index 0000000000..af281775d5 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/VPNFeedbackSent.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "VPNFeedbackSent.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/VPNFeedbackSent.imageset/VPNFeedbackSent.pdf b/DuckDuckGo/Assets.xcassets/Images/VPNFeedbackSent.imageset/VPNFeedbackSent.pdf new file mode 100644 index 0000000000000000000000000000000000000000..976e847ae85c8c45852e6aab6f7f13d5c4722006 GIT binary patch literal 5591 zcmeHLOK;pZ5WeeI@Dd;?5G^@;1A&3Yae|^PnmWA&J#1ygaj}nfcT=SK_5FsDhVrfp z?;*$R#quX|9^VXSMtt)2)$8Y`jZI^eR@*=RY?ONOLM^U7AKtt>ED!zgUnqaNrP6KC z{8JYfSb6eWT)DGX(%CEh*(>eYD@|Q#|JS373-+_$+%Pc>-DBvdesg>Hs4T#1UbR2$ z`sKP=yz2kC>-y_AFV*F{`G4KM`MX(M8FjmFOj}fH&^bOlm#5h~uU$$|FfHoV4fy9@ ztrP)4R0%E{PebKDw^6%F6XSOz$!&F z3)Ibw6mZq#)|qIdRf5Gf8;Y3`YaLXm(0Y$x1t8fX550o0$s}P3p0y#`Cd z`;>6pt@Aqb1C^7kxSQBh*{CR|V5#1kH`cl#=O2ar=v9?4t0@*GFDDiu8=M^Ox}##K zD`H7SR}G#=K^65Psj8`1mEJWKotrTV6rZYQfo5e#%#xQ6M5Ojy;%*XSMmH<{-Iy3W z2Vx2h{&&%oh!C2hMVIsnaSYzL=oCT%)JLBKqt0iSf?_OsLP&uT`eCGkrI3f+I$E6w z9h6dQ$mHY{YGCBHYI&u}T0<)ukV`fYc5{WqV^-*OF>|}-{%c$G{^SB%Y!kO>G+V?V zQ6wnGm|_Ss26ttbb&kNwc5f0uGyt6g>6spoWrzrTRC~arp=@Cy(l-WU3{(RoXE7jD z4*@xXz!?#MLU6DjtAaq#iuyL2fpP&4J0zd1Ot;c z9Em}7 zj&tS=!^p<3ZLrZ15+}7*Ln0DKR<4@Z+8CooiWPoXBna2!G22zmh>WqGw+<$bb0myL z&hpj-Vlh1;p;HX37v{UnIRs9u$FN&M?u`wuB$I|Ctd&~hY%q@3A&2n*2mz%c4U;Q& zvllqS1}CO#xG)3_Zj{kDQ&9)j0ZlO*6vAL4&*yNPd_YnqI0Gu;oRqP)5Fas4aZJ7_ z3T0(L{s7D^{MTV75a|WA#=+!KEe;XWY%`x7Hc+DsK@FoI!NNfCgfY;J(^DO0EVyI_ zW*jOOGXpg8S;lUmkWX+ToZK1H2!uw4aop!2L9T$P0Eod+^(H6OGkAK~PX zzz>vP2U3QwE6$%`+2ajSBr+MpCM36f70GK@DbMWhu#~Ia_*nPfGktO8&z_#jylPP; z9fFQyrcqxW{XWD1ji_^hL4CIl)n|?IJjR62C9c&OdGGLD5KXdtTH9oidW2KNCzXpM zig<|l{zR+9DpXspoG5)9JeU++Kw>*g{F=(gTt=<71_;^{=Xv=YII@8`;4gv1E9111 zogwRj;k?(GEK(eiL|0_fxa9?px{ccl^cS`*qR3bn0v!O^7bICgmC0z7yv{=wtn+P1 z+2b@r+Njh|bZ~1pHiXSXk3MpQ%{yKBfXEep0G)VzDzIdNd@P2u69Ik=`Q^|whNs69 zp>!22Mu^8f>n3ot9Us;SLoD?0o!gpdJOV06{Vqk-9aq^lQDOli31J^B5`BVn(1)ga z&xRop$`1ZI70Z~_L9&1x&Ox+jokOFHk_qEFr2ODRl_oOk!JMSdMpMzr=84gQav~g? zJU{A_#8urZdS{r(t9igTPvf=HbO*)PO~Wmn3(3jV6gtlA^6vZ1W_#GH_rDKUbc^5q z{i|1tx69jp1b*&s?v^jNpVfQrR0ECj5ZFcjsUX-S>AK%X4}qbGFl zBM76OK!@G(Zq@G^zIy%P73g@n->&;->gnChvw@Vw^>%wu>~JA?@$T+R59`at<8pT> P?s$xj&66irzr6krIlxNz literal 0 HcmV?d00001 diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackCategory.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackCategory.swift index 0d6f10cedc..288630804d 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackCategory.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackCategory.swift @@ -52,10 +52,10 @@ enum VPNFeedbackCategory: String, CaseIterable { case .landingPage: return "What's happening?" case .unableToInstall: return "Unable to install VPN" case .failsToConnect: return "VPN fails to connect" - case .tooSlow: return "VPN is too slow" - case .issueWithAppOrWebsite: return "Issue with app or website" + case .tooSlow: return "VPN connection is too slow" + case .issueWithAppOrWebsite: return "Issue with other apps or websites" case .cantConnectToLocalDevice: return "Can't connect to local device" - case .appCrashesOrFreezes: return "App crashes or freezes" + case .appCrashesOrFreezes: return "Browser crashes or freezes" case .featureRequest: return "Feature request" case .somethingElse: return "Something else" } diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index 9562426ea4..e255de3bee 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -164,7 +164,8 @@ private struct VPNFeedbackFormSentView: View { var body: some View { VStack(spacing: 0) { - Image("JoinWaitlistHeader") + Image("VPNFeedbackSent") + .padding(.top, 20) Text("Thank you!") .font(.system(size: 18, weight: .medium)) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift index cf1630cf07..4311945264 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewController.swift @@ -31,7 +31,7 @@ final class VPNFeedbackFormViewController: NSViewController { enum Constants { static let landingPageHeight = 260.0 static let feedbackFormHeight = 535.0 - static let feedbackSentHeight = 340.0 + static let feedbackSentHeight = 350.0 static let feedbackErrorHeight = 560.0 } From 2f0c2b7db1dd6e46f5970929c94a9c86a5165a0c Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Wed, 6 Dec 2023 12:44:43 -0800 Subject: [PATCH 21/34] Update the feedback form title font. --- DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index e255de3bee..b5beb847e5 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -29,7 +29,8 @@ struct VPNFeedbackFormView: View { VStack(spacing: 0) { Group { Text("Report an Issue") - .font(.title2) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.secondary) } .frame(height: 70) .frame(maxWidth: .infinity) From 24c61bc8ef0cb7a284e2b0ad02d00f46e1dd25a5 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Wed, 6 Dec 2023 21:41:40 -0800 Subject: [PATCH 22/34] Update copy per the ship review. --- .../UserText+NetworkProtection.swift | 16 +++++++++++++++- .../VPNFeedbackForm/VPNFeedbackCategory.swift | 18 +++++++++--------- .../VPNFeedbackForm/VPNFeedbackFormView.swift | 4 ++-- DuckDuckGoDBPBackgroundAgent/UserText.swift | 2 +- DuckDuckGoVPN/UserText.swift | 2 +- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift index ff2acbfdd4..d01d634a79 100644 --- a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift +++ b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift @@ -37,7 +37,7 @@ extension UserText { // MARK: - Navigation Bar Status View - static let networkProtectionNavBarStatusViewShareFeedback = NSLocalizedString("network.protection.navbar.status.view.share.feedback", value: "Share Feedback…", comment: "Menu item for 'Share Feedback' in the Network Protection status view that's shown in the navigation bar") + static let networkProtectionNavBarStatusViewShareFeedback = NSLocalizedString("network.protection.navbar.status.view.share.feedback", value: "Send Feedback…", comment: "Menu item for 'Send Feedback' in the Network Protection status view that's shown in the navigation bar") static let networkProtectionNavBarStatusMenuVPNSettings = NSLocalizedString("network.protection.status.menu.vpn.settings", value: "VPN Settings…", comment: "The status menu 'VPN Settings' menu item") // MARK: - System Extension Installation Messages @@ -153,6 +153,19 @@ extension UserText { static let networkProtectionTermsOfServiceSection8Title = NSLocalizedString("network-protection.terms-of-service.section.8.title", value: "We need your feedback.", comment: "Terms of Service title for Network Protection") static let networkProtectionTermsOfServiceSection8List = NSLocalizedString("network-protection.terms-of-service.section.8.list", value: "You may be asked during the beta period to provide feedback about your experience. Doing so is optional and your feedback may be used to improve the service.\n\nIf you have enabled notifications for the DuckDuckGo app, we may use notifications to ask about your experience. You can disable notifications if you do not want to receive them.", comment: "Terms of Service list for Network Protection") + // MARK: - Feedback Form + + static let vpnFeedbackFormTitle = NSLocalizedString("vpn.feedback-form.title", value: "Help Improve the DuckDuckGo VPN", comment: "Title for each screen of the VPN feedback form") + static let vpnFeedbackFormCategorySelect = NSLocalizedString("vpn.feedback-form.category.select-category", value: "Select a category", comment: "Title for the category selection state of the VPN feedback form") + static let vpnFeedbackFormCategoryUnableToInstall = NSLocalizedString("vpn.feedback-form.category.unable-to-install", value: "Unable to install VPN", comment: "Title for the 'unable to install' category of the VPN feedback form") + static let vpnFeedbackFormCategoryFailsToConnect = NSLocalizedString("vpn.feedback-form.category.fails-to-connect", value: "VPN fails to connect", comment: "Title for the 'VPN fails to connect' category of the VPN feedback form") + static let vpnFeedbackFormCategoryTooSlow = NSLocalizedString("vpn.feedback-form.category.too-slow", value: "VPN connection is too slow", comment: "Title for the 'VPN is too slow' category of the VPN feedback form") + static let vpnFeedbackFormCategoryIssuesWithApps = NSLocalizedString("vpn.feedback-form.category.issues-with-apps", value: "VPN causes issues with other apps or websites", comment: "Title for the category 'VPN causes issues with other apps or websites' category of the VPN feedback form") + static let vpnFeedbackFormCategoryLocalDeviceConnectivity = NSLocalizedString("vpn.feedback-form.category.local-device-connectivity", value: "VPN won't let me connect to local device", comment: "Title for the local device connectivity category of the VPN feedback form") + static let vpnFeedbackFormCategoryBrowserCrashOrFreeze = NSLocalizedString("vpn.feedback-form.category.browser-crash-or-freeze", value: "VPN causes browser to crash or freeze", comment: "Title for the browser crash/freeze category of the VPN feedback form") + static let vpnFeedbackFormCategoryFeatureRequest = NSLocalizedString("vpn.feedback-form.category.feature-request", value: "VPN feature request", comment: "Title for the 'VPN feature request' category of the VPN feedback form") + static let vpnFeedbackFormCategoryOther = NSLocalizedString("vpn.feedback-form.category.other", value: "Other VPN feedback", comment: "Title for the 'other VPN feedback' category of the VPN feedback form") + } #if DBP @@ -190,6 +203,7 @@ extension UserText { static let dataBrokerProtectionWaitlistButtonEnableNotifications = NSLocalizedString("data-broker-protection.waitlist.button.enable-notifications", value: "Enable Notifications", comment: "Enable Notifications button for Personal Information Removal joined waitlist screen") static let dataBrokerProtectionWaitlistButtonJoinWaitlist = NSLocalizedString("data-broker-protection.waitlist.button.join-waitlist", value: "Join the Waitlist", comment: "Join Waitlist button for Personal Information Removal join waitlist screen") static let dataBrokerProtectionWaitlistButtonAgreeAndContinue = NSLocalizedString("data-broker-protection.waitlist.button.agree-and-continue", value: "Agree and Continue", comment: "Agree and Continue button for Personal Information Removal join waitlist screen") + } #endif diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackCategory.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackCategory.swift index 288630804d..34adf335df 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackCategory.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackCategory.swift @@ -49,15 +49,15 @@ enum VPNFeedbackCategory: String, CaseIterable { var displayName: String { switch self { - case .landingPage: return "What's happening?" - case .unableToInstall: return "Unable to install VPN" - case .failsToConnect: return "VPN fails to connect" - case .tooSlow: return "VPN connection is too slow" - case .issueWithAppOrWebsite: return "Issue with other apps or websites" - case .cantConnectToLocalDevice: return "Can't connect to local device" - case .appCrashesOrFreezes: return "Browser crashes or freezes" - case .featureRequest: return "Feature request" - case .somethingElse: return "Something else" + case .landingPage: return UserText.vpnFeedbackFormCategorySelect + case .unableToInstall: return UserText.vpnFeedbackFormCategoryUnableToInstall + case .failsToConnect: return UserText.vpnFeedbackFormCategoryFailsToConnect + case .tooSlow: return UserText.vpnFeedbackFormCategoryTooSlow + case .issueWithAppOrWebsite: return UserText.vpnFeedbackFormCategoryIssuesWithApps + case .cantConnectToLocalDevice: return UserText.vpnFeedbackFormCategoryLocalDeviceConnectivity + case .appCrashesOrFreezes: return UserText.vpnFeedbackFormCategoryBrowserCrashOrFreeze + case .featureRequest: return UserText.vpnFeedbackFormCategoryFeatureRequest + case .somethingElse: return UserText.vpnFeedbackFormCategoryOther } } } diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index b5beb847e5..f1ebdc6ecb 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -28,7 +28,7 @@ struct VPNFeedbackFormView: View { var body: some View { VStack(spacing: 0) { Group { - Text("Report an Issue") + Text(UserText.vpnFeedbackFormTitle) .font(.system(size: 15, weight: .semibold)) .foregroundColor(.secondary) } @@ -172,7 +172,7 @@ private struct VPNFeedbackFormSentView: View { .font(.system(size: 18, weight: .medium)) .padding(.top, 30) - Text("Your feedback will help us improve the\nDuckDuckGo app.") + Text("Your feedback will help us improve the\nDuckDuckGo VPN.") .multilineTextAlignment(.center) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) diff --git a/DuckDuckGoDBPBackgroundAgent/UserText.swift b/DuckDuckGoDBPBackgroundAgent/UserText.swift index 227f211608..670a372511 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 Feedback...", comment: "The status menu 'Share Feedback' menu item") + static let networkProtectionStatusMenuShareFeedback = NSLocalizedString("network.protection.status.menu.share.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/UserText.swift b/DuckDuckGoVPN/UserText.swift index 1513654233..cbbb05f8b2 100644 --- a/DuckDuckGoVPN/UserText.swift +++ b/DuckDuckGoVPN/UserText.swift @@ -23,5 +23,5 @@ final class UserText { static let networkProtectionStatusMenuVPNSettings = NSLocalizedString("network.protection.status.menu.vpn.settings", value: "VPN Settings…", comment: "The status menu 'VPN Settings' 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 Feedback…", comment: "The status menu 'Share Feedback' menu item") + static let networkProtectionStatusMenuShareFeedback = NSLocalizedString("network.protection.status.menu.share.feedback", value: "Send Feedback…", comment: "The status menu 'Send Feedback' menu item") } From 1babeb767a755dfd5324f2b08ad219510dff5da7 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Thu, 7 Dec 2023 10:06:40 -0800 Subject: [PATCH 23/34] Fix the macOS 11 text editor compilation. --- .../View/SwiftUI/FocusableTextEditor.swift | 2 +- .../VPNFeedbackForm/VPNFeedbackFormView.swift | 23 +++++++++---------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/DuckDuckGo/Common/View/SwiftUI/FocusableTextEditor.swift b/DuckDuckGo/Common/View/SwiftUI/FocusableTextEditor.swift index 3dcd13b55d..2522593b97 100644 --- a/DuckDuckGo/Common/View/SwiftUI/FocusableTextEditor.swift +++ b/DuckDuckGo/Common/View/SwiftUI/FocusableTextEditor.swift @@ -26,7 +26,7 @@ struct FocusableTextEditor: View { let cornerRadius: CGFloat = 8.0 let borderWidth: CGFloat = 0.4 - let characterLimit: Int = 10000 + var characterLimit: Int = 10000 var body: some View { TextEditor(text: $text) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index f1ebdc6ecb..213e406479 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -135,26 +135,25 @@ private struct VPNFeedbackFormIssueDescriptionForm: View { FocusableTextEditor(text: $model.notes) #else if #available(macOS 12, *) { - FocusableTextEditor(text: $viewModel.feedbackFormText) + 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(10000)) + viewModel.feedbackFormText = String($0.prefix(1000)) } .padding(EdgeInsets(top: 3.0, leading: 6.0, bottom: 5.0, trailing: 0.0)) - // .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - // .background( - // ZStack { - // RoundedRectangle(cornerRadius: cornerRadius) - // .stroke(Color(NSColor.textEditorBorderColor), lineWidth: 0.4) - // RoundedRectangle(cornerRadius: cornerRadius) - // .fill(Color(NSColor.textEditorBackgroundColor)) - // } - // ) - // ^ the code above is failing to compile with "failed to produce diagnostic for expression", need to debug it further. + .clipShape(RoundedRectangle(cornerRadius: 8.0, style: .continuous)) + .background( + ZStack { + RoundedRectangle(cornerRadius: 8.0) + .stroke(Color(NSColor.textEditorBorderColor), lineWidth: 0.4) + RoundedRectangle(cornerRadius: 8.0) + .fill(Color(NSColor.textEditorBackgroundColor)) + } + ) } #endif } From 5a8d9f13ee66eeffa671e7d2739034e2874f146c Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Thu, 7 Dec 2023 10:17:51 -0800 Subject: [PATCH 24/34] Clean up buttons. --- .../VPNFeedbackForm/VPNFeedbackFormView.swift | 46 ++++++++----------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index 213e406479..02ed95cb91 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -188,39 +188,29 @@ private struct VPNFeedbackFormButtons: View { var body: some View { HStack { if viewModel.viewState == .feedbackSent { - Button(action: { - viewModel.process(action: .cancel) - }, label: { - Text("Done") - .frame(maxWidth: .infinity) - }) - .keyboardShortcut(.defaultAction) - .controlSize(.large) - .frame(maxWidth: .infinity) + button(text: "Done", action: .cancel) + .keyboardShortcut(.defaultAction) } else { - Button(action: { - viewModel.process(action: .cancel) - }, label: { - Text("Cancel") - .frame(maxWidth: .infinity) - }) - .controlSize(.large) - .frame(maxWidth: .infinity) - - Button(action: { - viewModel.process(action: .submit) - }, label: { - Text(viewModel.viewState == .feedbackSending ? "Submitting..." : "Submit") - .frame(maxWidth: .infinity) - }) - .keyboardShortcut(.defaultAction) - .controlSize(.large) - .frame(maxWidth: .infinity) - .disabled(!viewModel.submitButtonEnabled) + button(text: "Cancel", action: .cancel) + button(text: viewModel.viewState == .feedbackSending ? "Submitting..." : "Submit", action: .submit) + .keyboardShortcut(.defaultAction) + .disabled(!viewModel.submitButtonEnabled) } } } + @ViewBuilder + func button(text: String, action: VPNFeedbackFormViewModel.ViewAction) -> some View { + Button(action: { + viewModel.process(action: action) + }, label: { + Text(text) + .frame(maxWidth: .infinity) + }) + .controlSize(.large) + .frame(maxWidth: .infinity) + } + } #endif From 4085b5cf07f299a8d70ab5829fa1c9ccf077c6e4 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Thu, 7 Dec 2023 17:21:46 -0800 Subject: [PATCH 25/34] Add VPN settings to the metadata collector. --- .../VPNMetadataCollector.swift | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift index 6c4da29223..f5c12ba7d4 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift @@ -46,7 +46,18 @@ struct VPNMetadata: Encodable { struct VPNState: Encodable { let onboardingState: String let connectionState: String - let serverLocation: String + let connectedServer: String + } + + struct VPNSettingsState: Encodable { + let connectOnLoginEnabled: Bool + let includeAllNetworksEnabled: Bool + let enforceRoutesEnabled: Bool + let excludeLocalNetworksEnabled: Bool + let notifyStatusChangesEnabled: Bool + let showInMenuBarEnabled: Bool + let selectedServer: String + let selectedEnvironment: String } struct LoginItemState: Encodable { @@ -61,6 +72,7 @@ struct VPNMetadata: Encodable { let deviceInfo: DeviceInfo let networkInfo: NetworkInfo let vpnState: VPNState + let vpnSettingsState: VPNSettingsState let loginItemState: LoginItemState func toPrettyPrintedJSON() -> String? { @@ -120,6 +132,7 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { let deviceInfoMetadata = collectDeviceInfoMetadata() let networkInfoMetadata = await collectNetworkInformation() let vpnState = await collectVPNState() + let vpnSettingsState = collectVPNSettingsState() let loginItemState = collectLoginItemState() return VPNMetadata( @@ -127,6 +140,7 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { deviceInfo: deviceInfoMetadata, networkInfo: networkInfoMetadata, vpnState: vpnState, + vpnSettingsState: vpnSettingsState, loginItemState: loginItemState ) } @@ -199,8 +213,8 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { } let connectionState = String(describing: statusReporter.statusObserver.recentValue) - let serverLocation = statusReporter.serverInfoObserver.recentValue.serverLocation ?? "No Location" - return .init(onboardingState: onboardingState, connectionState: connectionState, serverLocation: serverLocation) + let connectedServer = statusReporter.serverInfoObserver.recentValue.serverLocation ?? "none" + return .init(onboardingState: onboardingState, connectionState: connectionState, connectedServer: connectedServer) } func collectLoginItemState() -> VPNMetadata.LoginItemState { @@ -214,6 +228,21 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { #endif } + func collectVPNSettingsState() -> VPNMetadata.VPNSettingsState { + let settings = VPNSettings(defaults: .netP) + + return .init( + connectOnLoginEnabled: settings.connectOnLogin, + includeAllNetworksEnabled: settings.includeAllNetworks, + enforceRoutesEnabled: settings.enforceRoutes, + excludeLocalNetworksEnabled: settings.excludeLocalNetworks, + notifyStatusChangesEnabled: settings.notifyStatusChanges, + showInMenuBarEnabled: settings.showInMenuBar, + selectedServer: settings.selectedServer.stringValue ?? "automatic", + selectedEnvironment: settings.selectedEnvironment.rawValue + ) + } + } #endif From 58e65782fa49ed9b6044028a239066dd7916b447 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Thu, 7 Dec 2023 18:12:19 -0800 Subject: [PATCH 26/34] Add the last error message and connected server IP. --- .../VPNFeedbackForm/VPNMetadataCollector.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift index f5c12ba7d4..fc320fd971 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift @@ -22,6 +22,7 @@ import Foundation import Common import LoginItems import NetworkProtection +import NetworkExtension import NetworkProtectionIPC import NetworkProtectionUI @@ -46,7 +47,9 @@ struct VPNMetadata: Encodable { struct VPNState: Encodable { let onboardingState: String let connectionState: String + let lastErrorMessage: String let connectedServer: String + let connectedServerIP: String } struct VPNSettingsState: Encodable { @@ -178,7 +181,7 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { let monitor = NWPathMonitor() monitor.start(queue: DispatchQueue(label: "VPNMetadataCollector.NWPathMonitor.paths")) - var path: NWPath? + var path: Network.NWPath? let startTime = CFAbsoluteTimeGetCurrent() while true { @@ -213,8 +216,14 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { } let connectionState = String(describing: statusReporter.statusObserver.recentValue) + let lastErrorMessage = statusReporter.connectionErrorObserver.recentValue ?? "none" let connectedServer = statusReporter.serverInfoObserver.recentValue.serverLocation ?? "none" - return .init(onboardingState: onboardingState, connectionState: connectionState, connectedServer: connectedServer) + let connectedServerIP = statusReporter.serverInfoObserver.recentValue.serverAddress ?? "none" + return .init(onboardingState: onboardingState, + connectionState: connectionState, + lastErrorMessage: lastErrorMessage, + connectedServer: connectedServer, + connectedServerIP: connectedServerIP) } func collectLoginItemState() -> VPNMetadata.LoginItemState { From 83317e8b490d0908308ced55137e5fd2e915cb20 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Fri, 8 Dec 2023 07:32:42 -0800 Subject: [PATCH 27/34] Clean up button titles text. --- .../Common/Localizables/UserText+NetworkProtection.swift | 5 +++++ DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift index d01d634a79..ed07ba134a 100644 --- a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift +++ b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift @@ -166,6 +166,11 @@ extension UserText { static let vpnFeedbackFormCategoryFeatureRequest = NSLocalizedString("vpn.feedback-form.category.feature-request", value: "VPN feature request", comment: "Title for the 'VPN feature request' category of the VPN feedback form") static let vpnFeedbackFormCategoryOther = NSLocalizedString("vpn.feedback-form.category.other", value: "Other VPN feedback", comment: "Title for the 'other VPN feedback' category of the VPN feedback form") + static let vpnFeedbackFormButtonDone = NSLocalizedString("vpn.feedback-form.button.done", value: "Done", comment: "Title for the Done button of the VPN feedback form") + static let vpnFeedbackFormButtonCancel = NSLocalizedString("vpn.feedback-form.button.cancel", value: "Cancel", comment: "Title for the Cancel button of the VPN feedback form") + static let vpnFeedbackFormButtonSubmit = NSLocalizedString("vpn.feedback-form.button.submit", value: "Submit", comment: "Title for the Submit button of the VPN feedback form") + static let vpnFeedbackFormButtonSubmitting = NSLocalizedString("vpn.feedback-form.button.submitting", value: "Submitting…", comment: "Title for the Submitting state of the VPN feedback form") + } #if DBP diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index 02ed95cb91..70fec61ec2 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -188,11 +188,11 @@ private struct VPNFeedbackFormButtons: View { var body: some View { HStack { if viewModel.viewState == .feedbackSent { - button(text: "Done", action: .cancel) + button(text: UserText.vpnFeedbackFormButtonDone, action: .cancel) .keyboardShortcut(.defaultAction) } else { - button(text: "Cancel", action: .cancel) - button(text: viewModel.viewState == .feedbackSending ? "Submitting..." : "Submit", action: .submit) + button(text: UserText.vpnFeedbackFormButtonCancel, action: .cancel) + button(text: viewModel.viewState == .feedbackSending ? UserText.vpnFeedbackFormButtonSubmitting : UserText.vpnFeedbackFormButtonSubmit, action: .submit) .keyboardShortcut(.defaultAction) .disabled(!viewModel.submitButtonEnabled) } From e4fcba70657b202c37721515427fbf2d6b2f031f Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Fri, 8 Dec 2023 07:34:19 -0800 Subject: [PATCH 28/34] Clean up strings for the VPN sent state. --- .../Common/Localizables/UserText+NetworkProtection.swift | 3 +++ DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift index ed07ba134a..f70cdad4a7 100644 --- a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift +++ b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift @@ -166,6 +166,9 @@ extension UserText { static let vpnFeedbackFormCategoryFeatureRequest = NSLocalizedString("vpn.feedback-form.category.feature-request", value: "VPN feature request", comment: "Title for the 'VPN feature request' category of the VPN feedback form") static let vpnFeedbackFormCategoryOther = NSLocalizedString("vpn.feedback-form.category.other", value: "Other VPN feedback", comment: "Title for the 'other VPN feedback' category of the VPN feedback form") + static let vpnFeedbackFormSendingConfirmationTitle = NSLocalizedString("vpn.feedback-form.sending-confirmation.title", value: "Thank you!", comment: "Title for the feedback sent view title of the VPN feedback form") + static let vpnFeedbackFormSendingConfirmationDescription = NSLocalizedString("vpn.feedback-form.sending-confirmation.description", value: "Your feedback will help us improve the\nDuckDuckGo VPN.", comment: "Title for the feedback sent view description of the VPN feedback form") + static let vpnFeedbackFormButtonDone = NSLocalizedString("vpn.feedback-form.button.done", value: "Done", comment: "Title for the Done button of the VPN feedback form") static let vpnFeedbackFormButtonCancel = NSLocalizedString("vpn.feedback-form.button.cancel", value: "Cancel", comment: "Title for the Cancel button of the VPN feedback form") static let vpnFeedbackFormButtonSubmit = NSLocalizedString("vpn.feedback-form.button.submit", value: "Submit", comment: "Title for the Submit button of the VPN feedback form") diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index 70fec61ec2..44fc3f95c8 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -167,11 +167,11 @@ private struct VPNFeedbackFormSentView: View { Image("VPNFeedbackSent") .padding(.top, 20) - Text("Thank you!") + Text(UserText.vpnFeedbackFormSendingConfirmationTitle) .font(.system(size: 18, weight: .medium)) .padding(.top, 30) - Text("Your feedback will help us improve the\nDuckDuckGo VPN.") + Text(UserText.vpnFeedbackFormSendingConfirmationDescription) .multilineTextAlignment(.center) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) From edafbfefeced5c41168940b4e9ae0173fb9efa44 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Fri, 8 Dec 2023 07:38:23 -0800 Subject: [PATCH 29/34] Clean up the VPN feedback form error state. --- DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift | 1 + DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift index f70cdad4a7..1ca7a21d18 100644 --- a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift +++ b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift @@ -168,6 +168,7 @@ extension UserText { static let vpnFeedbackFormSendingConfirmationTitle = NSLocalizedString("vpn.feedback-form.sending-confirmation.title", value: "Thank you!", comment: "Title for the feedback sent view title of the VPN feedback form") static let vpnFeedbackFormSendingConfirmationDescription = NSLocalizedString("vpn.feedback-form.sending-confirmation.description", value: "Your feedback will help us improve the\nDuckDuckGo VPN.", comment: "Title for the feedback sent view description of the VPN feedback form") + static let vpnFeedbackFormSendingConfirmationError = NSLocalizedString("vpn.feedback-form.sending-confirmation.error", value: "We couldn't send your feedback right now, please try again.", comment: "Title for the feedback sending error text of the VPN feedback form") static let vpnFeedbackFormButtonDone = NSLocalizedString("vpn.feedback-form.button.done", value: "Done", comment: "Title for the Done button of the VPN feedback form") static let vpnFeedbackFormButtonCancel = NSLocalizedString("vpn.feedback-form.button.cancel", value: "Cancel", comment: "Title for the Cancel button of the VPN feedback form") diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index 44fc3f95c8..bc9af072fe 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -44,7 +44,7 @@ struct VPNFeedbackFormView: View { .padding([.top, .leading, .trailing], 20) if viewModel.viewState == .feedbackSendingFailed { - Text("We couldn't send your feedback right now, please try again.") + Text(UserText.vpnFeedbackFormSendingConfirmationError) .foregroundColor(.red) .padding(.top, 15) } From 8cf7e64e020ddfa0a8080f7da04ca640cd3788c0 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Fri, 8 Dec 2023 07:42:10 -0800 Subject: [PATCH 30/34] Clean up remaining VPN form strings. --- .../Localizables/UserText+NetworkProtection.swift | 6 ++++++ DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift | 10 +++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift index 1ca7a21d18..29e6bf9e93 100644 --- a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift +++ b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift @@ -166,6 +166,12 @@ extension UserText { static let vpnFeedbackFormCategoryFeatureRequest = NSLocalizedString("vpn.feedback-form.category.feature-request", value: "VPN feature request", comment: "Title for the 'VPN feature request' category of the VPN feedback form") static let vpnFeedbackFormCategoryOther = NSLocalizedString("vpn.feedback-form.category.other", value: "Other VPN feedback", comment: "Title for the 'other VPN feedback' category of the VPN feedback form") + static let vpnFeedbackFormText1 = NSLocalizedString("vpn.feedback-form.text-1", value: "Please describe what's happening, what you expected to happen, and the steps that led to the issue:", comment: "Text for the body of the VPN feedback form") + static let vpnFeedbackFormText2 = NSLocalizedString("vpn.feedback-form.text-2", value: "In addition to the details entered into this form, your app issue report will contain:", comment: "Text for the body of the VPN feedback form") + static let vpnFeedbackFormText3 = NSLocalizedString("vpn.feedback-form.text-3", value: "• Whether specific DuckDuckGo features are enabled", comment: "Bullet text for the body of the VPN feedback form") + static let vpnFeedbackFormText4 = NSLocalizedString("vpn.feedback-form.text-4", value: "• Aggregate DuckDuckGo app diagnostics", comment: "Bullet text for the body of the VPN feedback form") + static let vpnFeedbackFormText5 = NSLocalizedString("vpn.feedback-form.text-5", value: "By clicking \"Submit\" I agree that DuckDuckGo may use the information in this report for purposes of improving the app's features.", comment: "Text for the body of the VPN feedback form") + static let vpnFeedbackFormSendingConfirmationTitle = NSLocalizedString("vpn.feedback-form.sending-confirmation.title", value: "Thank you!", comment: "Title for the feedback sent view title of the VPN feedback form") static let vpnFeedbackFormSendingConfirmationDescription = NSLocalizedString("vpn.feedback-form.sending-confirmation.description", value: "Your feedback will help us improve the\nDuckDuckGo VPN.", comment: "Title for the feedback sent view description of the VPN feedback form") static let vpnFeedbackFormSendingConfirmationError = NSLocalizedString("vpn.feedback-form.sending-confirmation.error", value: "We couldn't send your feedback right now, please try again.", comment: "Title for the feedback sending error text of the VPN feedback form") diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index bc9af072fe..cb2381f120 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -101,27 +101,27 @@ private struct VPNFeedbackFormIssueDescriptionForm: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - Text("Please describe what's happening, what you expected to happen, and the steps that led to the issue:") + Text(UserText.vpnFeedbackFormText1) .multilineTextAlignment(.leading) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) textEditor() - Text("In addition to the details entered into this form, your app issue report will contain:") + Text(UserText.vpnFeedbackFormText2) .multilineTextAlignment(.leading) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) .foregroundColor(.secondary) VStack(alignment: .leading) { - Text("• Whether specific DuckDuckGo features are enabled") + Text(UserText.vpnFeedbackFormText3) .foregroundColor(.secondary) - Text("• Aggregate DuckDuckGo app diagnostics") + Text(UserText.vpnFeedbackFormText4) .foregroundColor(.secondary) } - Text("By clicking \"Submit\" I agree that DuckDuckGo may use the information in this report for purposes of improving the app's features.") + Text(UserText.vpnFeedbackFormText5) .multilineTextAlignment(.leading) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) From dd2a046ae98065c3b89800358b628e855bc64fdd Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Fri, 8 Dec 2023 07:52:45 -0800 Subject: [PATCH 31/34] Clean up submit button logic. --- .../VPNFeedbackForm/VPNFeedbackFormViewModel.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift index 88a9b7c49c..6c7a2d0eeb 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift @@ -33,6 +33,15 @@ final class VPNFeedbackFormViewModel: ObservableObject { 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 { @@ -88,7 +97,7 @@ final class VPNFeedbackFormViewModel: ObservableObject { } private func updateSubmitButtonStatus() { - self.submitButtonEnabled = (viewState == .feedbackPending || viewState == .feedbackSendingFailed) && !feedbackFormText.isEmpty + self.submitButtonEnabled = viewState.canSubmit && !feedbackFormText.isEmpty } } From 7599d682b16c29e1214f0151561b04cd3f8e06a4 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Fri, 8 Dec 2023 09:46:38 -0800 Subject: [PATCH 32/34] Begin adding unit tests. --- DuckDuckGo.xcodeproj/project.pbxproj | 14 ++ .../VPNFeedbackFormViewModelTests.swift | 122 ++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index ca8d63f8bc..b0ca07d341 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2120,6 +2120,8 @@ 4BD57C052AC112DF00B580EE /* NetworkProtectionRemoteMessagingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57C032AC112DF00B580EE /* NetworkProtectionRemoteMessagingTests.swift */; }; 4BDFA4AE27BF19E500648192 /* ToggleableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */; }; 4BE0DF06267819A1006337B7 /* NSStoryboardExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE0DF0426781961006337B7 /* NSStoryboardExtension.swift */; }; + 4BE344EE2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE344ED2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift */; }; + 4BE344EF2B23786F003FC223 /* VPNFeedbackFormViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE344ED2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift */; }; 4BE4005327CF3DC3007D3161 /* SavePaymentMethodPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE4005227CF3DC3007D3161 /* SavePaymentMethodPopover.swift */; }; 4BE4005527CF3F19007D3161 /* SavePaymentMethodViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE4005427CF3F19007D3161 /* SavePaymentMethodViewController.swift */; }; 4BE41A5E28446EAD00760399 /* BookmarksBarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE41A5D28446EAD00760399 /* BookmarksBarViewModel.swift */; }; @@ -3566,6 +3568,7 @@ 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleableScrollView.swift; sourceTree = ""; }; 4BE0DF0426781961006337B7 /* NSStoryboardExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSStoryboardExtension.swift; sourceTree = ""; }; 4BE15DB12A0B0DD500898243 /* PixelKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = PixelKit; sourceTree = ""; }; + 4BE344ED2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewModelTests.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 = ""; }; 4BE41A5D28446EAD00760399 /* BookmarksBarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksBarViewModel.swift; sourceTree = ""; }; @@ -5750,6 +5753,14 @@ path = View; sourceTree = ""; }; + 4BE344EC2B2376AE003FC223 /* VPNFeedbackForm */ = { + isa = PBXGroup; + children = ( + 4BE344ED2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift */, + ); + path = VPNFeedbackForm; + sourceTree = ""; + }; 4BF6961B28BE90E800D402D4 /* HomePage */ = { isa = PBXGroup; children = ( @@ -6520,6 +6531,7 @@ 4B9DB04D2A983B55000927DB /* Waitlist */, 3776582B27F7163B009A6B35 /* WebsiteBreakageReport */, 376718FE28E58504003A2A15 /* YoutubePlayer */, + 4BE344EC2B2376AE003FC223 /* VPNFeedbackForm */, AA585D96248FD31400E9A3E2 /* Info.plist */, ); path = UnitTests; @@ -9878,6 +9890,7 @@ 3706FE0F293F661700E42796 /* CSVLoginExporterTests.swift in Sources */, 3706FE10293F661700E42796 /* TestNavigationDelegate.swift in Sources */, 3706FE11293F661700E42796 /* URLSuggestedFilenameTests.swift in Sources */, + 4BE344EF2B23786F003FC223 /* VPNFeedbackFormViewModelTests.swift in Sources */, B6F56569299A414300A04298 /* WKWebViewMockingExtension.swift in Sources */, 3706FE13293F661700E42796 /* ConfigurationStorageTests.swift in Sources */, 3706FE14293F661700E42796 /* DownloadListStoreMock.swift in Sources */, @@ -11749,6 +11762,7 @@ B6A5A2A025B96E8300AA7ADA /* AppStateChangePublisherTests.swift in Sources */, B63ED0E326B3E7FA00A9DAD1 /* CLLocationManagerMock.swift in Sources */, 37CD54BB27F25A4000F1F7B9 /* DownloadsPreferencesTests.swift in Sources */, + 4BE344EE2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift in Sources */, 4B9DB0562A983B55000927DB /* MockNotificationService.swift in Sources */, 4B02199C25E063DE00ED7DEA /* FireproofDomainsTests.swift in Sources */, AA0F3DB7261A566C0077F2D9 /* SuggestionLoadingMock.swift in Sources */, diff --git a/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift b/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift new file mode 100644 index 0000000000..bc842d6213 --- /dev/null +++ b/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift @@ -0,0 +1,122 @@ +// +// VPNFeedbackFormViewModelTests.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 + +#if NETWORK_PROTECTION + +final class VPNFeedbackFormViewModelTests: XCTestCase { + + func testWhenCancelActionIsReceived_ThenViewModelSendsCancelActionToDelegate() throws { + let collector = MockVPNMetadataCollector() + let sender = MockVPNFeedbackSender() + let delegate = MockVPNFeedbackFormViewModelDelegate() + let viewModel = VPNFeedbackFormViewModel(metadataCollector: collector, feedbackSender: sender) + viewModel.delegate = delegate + + XCTAssertFalse(delegate.receivedDismissedViewCallback) + viewModel.process(action: .cancel) + XCTAssertTrue(delegate.receivedDismissedViewCallback) + } + + func testWhenCancelActionIsReceived_ThenViewModelSendsCancelActionToDelegate() throws { + let collector = MockVPNMetadataCollector() + let sender = MockVPNFeedbackSender() + let delegate = MockVPNFeedbackFormViewModelDelegate() + let viewModel = VPNFeedbackFormViewModel(metadataCollector: collector, feedbackSender: sender) + viewModel.delegate = delegate + + XCTAssertFalse(delegate.receivedDismissedViewCallback) + viewModel.process(action: .cancel) + XCTAssertTrue(delegate.receivedDismissedViewCallback) + } + +} + +// MARK: - Mocks + +private class MockVPNMetadataCollector: VPNMetadataCollector { + + var collectedMetadata: Bool = false + + func collectMetadata() async -> VPNMetadata { + self.collectedMetadata = true + + let appInfo = VPNMetadata.AppInfo(appVersion: "1.2.3", lastVersionRun: "1.2.3", isInternalUser: false) + let deviceInfo = VPNMetadata.DeviceInfo(osVersion: "14.0.0", buildFlavor: "dmg", lowPowerModeEnabled: false) + let networkInfo = VPNMetadata.NetworkInfo(currentPath: "path") + + let vpnState = VPNMetadata.VPNState( + onboardingState: "onboarded", + connectionState: "connected", + lastErrorMessage: "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" + ) + + let loginItemState = VPNMetadata.LoginItemState( + vpnMenuState: "enabled", + notificationsAgentState: "enabled" + ) + + return VPNMetadata( + appInfo: appInfo, + deviceInfo: deviceInfo, + networkInfo: networkInfo, + vpnState: vpnState, + vpnSettingsState: vpnSettingsState, + loginItemState: loginItemState + ) + } + +} + +private class MockVPNFeedbackSender: VPNFeedbackSender { + + var throwErrorWhenSending: Bool = false + var sentMetadata: Bool = false + + func send(metadata: VPNMetadata, category: VPNFeedbackCategory, userText: String) async throws { + self.sentMetadata = true + } + +} + +private class MockVPNFeedbackFormViewModelDelegate: VPNFeedbackFormViewModelDelegate { + + var receivedDismissedViewCallback: Bool = false + + func vpnFeedbackViewModelDismissedView(_ viewModel: VPNFeedbackFormViewModel) { + receivedDismissedViewCallback = true + } + +} + +#endif From 22ccff5c56ab56f04719b0d141aa50ba2a0e7756 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Fri, 8 Dec 2023 11:14:07 -0800 Subject: [PATCH 33/34] Fix up tests. --- .../VPNFeedbackForm/VPNFeedbackFormView.swift | 4 ++- .../VPNFeedbackFormViewModel.swift | 17 +++++----- .../VPNFeedbackFormViewModelTests.swift | 31 ++++++++++++++----- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift index cb2381f120..9f909044eb 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormView.swift @@ -202,7 +202,9 @@ private struct VPNFeedbackFormButtons: View { @ViewBuilder func button(text: String, action: VPNFeedbackFormViewModel.ViewAction) -> some View { Button(action: { - viewModel.process(action: action) + Task { + await viewModel.process(action: action) + } }, label: { Text(text) .frame(maxWidth: .infinity) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift index 6c7a2d0eeb..76282a51af 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNFeedbackFormViewModel.swift @@ -77,21 +77,20 @@ final class VPNFeedbackFormViewModel: ObservableObject { self.feedbackSender = feedbackSender } - func process(action: ViewAction) { + @MainActor + func process(action: ViewAction) async { switch action { case .cancel: delegate?.vpnFeedbackViewModelDismissedView(self) case .submit: self.viewState = .feedbackSending - Task { @MainActor in - do { - let metadata = await self.metadataCollector.collectMetadata() - try await self.feedbackSender.send(metadata: metadata, category: selectedFeedbackCategory, userText: feedbackFormText) - self.viewState = .feedbackSent - } catch { - self.viewState = .feedbackSendingFailed - } + do { + let metadata = await self.metadataCollector.collectMetadata() + try await self.feedbackSender.send(metadata: metadata, category: selectedFeedbackCategory, userText: feedbackFormText) + self.viewState = .feedbackSent + } catch { + self.viewState = .feedbackSendingFailed } } } diff --git a/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift b/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift index bc842d6213..b9cc72aea9 100644 --- a/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift +++ b/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift @@ -23,19 +23,33 @@ import XCTest final class VPNFeedbackFormViewModelTests: XCTestCase { - func testWhenCancelActionIsReceived_ThenViewModelSendsCancelActionToDelegate() throws { + func testWhenCreatingViewModel_ThenInitialStateIsFeedbackPending() throws { let collector = MockVPNMetadataCollector() let sender = MockVPNFeedbackSender() let delegate = MockVPNFeedbackFormViewModelDelegate() let viewModel = VPNFeedbackFormViewModel(metadataCollector: collector, feedbackSender: sender) - viewModel.delegate = delegate - XCTAssertFalse(delegate.receivedDismissedViewCallback) - viewModel.process(action: .cancel) - XCTAssertTrue(delegate.receivedDismissedViewCallback) + XCTAssertEqual(viewModel.viewState, .feedbackPending) + XCTAssertEqual(viewModel.selectedFeedbackCategory, .landingPage) + } + + func testWhenSendingFeedback_ThenFeedbackIsSent() async throws { + let collector = MockVPNMetadataCollector() + let sender = MockVPNFeedbackSender() + let delegate = MockVPNFeedbackFormViewModelDelegate() + let viewModel = VPNFeedbackFormViewModel(metadataCollector: collector, feedbackSender: sender) + let text = "Some feedback report text" + viewModel.selectedFeedbackCategory = .unableToInstall + viewModel.feedbackFormText = text + + XCTAssertFalse(sender.sentMetadata) + await viewModel.process(action: .submit) + XCTAssertTrue(sender.sentMetadata) + XCTAssertEqual(sender.receivedData!.1, .unableToInstall) + XCTAssertEqual(sender.receivedData!.2, text) } - func testWhenCancelActionIsReceived_ThenViewModelSendsCancelActionToDelegate() throws { + func testWhenCancelActionIsReceived_ThenViewModelSendsCancelActionToDelegate() async throws { let collector = MockVPNMetadataCollector() let sender = MockVPNFeedbackSender() let delegate = MockVPNFeedbackFormViewModelDelegate() @@ -43,7 +57,7 @@ final class VPNFeedbackFormViewModelTests: XCTestCase { viewModel.delegate = delegate XCTAssertFalse(delegate.receivedDismissedViewCallback) - viewModel.process(action: .cancel) + await viewModel.process(action: .cancel) XCTAssertTrue(delegate.receivedDismissedViewCallback) } @@ -103,8 +117,11 @@ private class MockVPNFeedbackSender: VPNFeedbackSender { var throwErrorWhenSending: Bool = false var sentMetadata: Bool = false + var receivedData: (VPNMetadata, VPNFeedbackCategory, String)? + func send(metadata: VPNMetadata, category: VPNFeedbackCategory, userText: String) async throws { self.sentMetadata = true + self.receivedData = (metadata, category, userText) } } From 29ba688f1c52374dab185c345cd912b93b5cfc4a Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Fri, 8 Dec 2023 11:22:08 -0800 Subject: [PATCH 34/34] Test the failure state. --- .../VPNFeedbackFormViewModelTests.swift | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift b/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift index b9cc72aea9..e325cf4516 100644 --- a/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift +++ b/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift @@ -33,7 +33,7 @@ final class VPNFeedbackFormViewModelTests: XCTestCase { XCTAssertEqual(viewModel.selectedFeedbackCategory, .landingPage) } - func testWhenSendingFeedback_ThenFeedbackIsSent() async throws { + func testWhenSendingFeedbackSucceeds_ThenFeedbackIsSent() async throws { let collector = MockVPNMetadataCollector() let sender = MockVPNFeedbackSender() let delegate = MockVPNFeedbackFormViewModelDelegate() @@ -49,6 +49,22 @@ final class VPNFeedbackFormViewModelTests: XCTestCase { XCTAssertEqual(sender.receivedData!.2, text) } + func testWhenSendingFeedbackFails_ThenFeedbackIsNotSent() async throws { + let collector = MockVPNMetadataCollector() + let sender = MockVPNFeedbackSender() + let delegate = MockVPNFeedbackFormViewModelDelegate() + let viewModel = VPNFeedbackFormViewModel(metadataCollector: collector, feedbackSender: sender) + let text = "Some feedback report text" + viewModel.selectedFeedbackCategory = .unableToInstall + 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() @@ -119,7 +135,15 @@ private class MockVPNFeedbackSender: VPNFeedbackSender { var receivedData: (VPNMetadata, VPNFeedbackCategory, String)? + enum SomeError: Error { + case error + } + func send(metadata: VPNMetadata, category: VPNFeedbackCategory, userText: String) async throws { + if throwErrorWhenSending { + throw SomeError.error + } + self.sentMetadata = true self.receivedData = (metadata, category, userText) }