diff --git a/DuckDuckGo/NetworkProtectionStatusView.swift b/DuckDuckGo/NetworkProtectionStatusView.swift index 21e79aa654..2fbeeb0558 100644 --- a/DuckDuckGo/NetworkProtectionStatusView.swift +++ b/DuckDuckGo/NetworkProtectionStatusView.swift @@ -20,6 +20,7 @@ import SwiftUI import NetworkProtection import TipKit +import Networking struct NetworkProtectionStatusView: View { @@ -309,7 +310,10 @@ struct NetworkProtectionStatusView: View { @ViewBuilder private func about() -> some View { - let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: DefaultVPNMetadataCollector(), source: .vpn) + let viewModel = UnifiedFeedbackFormViewModel(accountManager: AppDependencyProvider.shared.accountManager, + apiService: DefaultAPIService(), + vpnMetadataCollector: DefaultVPNMetadataCollector(), + source: .vpn) Section { NavigationLink(UserText.netPVPNSettingsFAQ, destination: LazyView(NetworkProtectionFAQView())) diff --git a/DuckDuckGo/SettingsOthersView.swift b/DuckDuckGo/SettingsOthersView.swift index 96f3cab8d1..bcb4d39b34 100644 --- a/DuckDuckGo/SettingsOthersView.swift +++ b/DuckDuckGo/SettingsOthersView.swift @@ -19,6 +19,7 @@ import SwiftUI import UIKit +import Networking struct SettingsOthersView: View { @@ -34,7 +35,10 @@ struct SettingsOthersView: View { // Share Feedback if viewModel.usesUnifiedFeedbackForm { - let formViewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: DefaultVPNMetadataCollector(), source: .settings) + let formViewModel = UnifiedFeedbackFormViewModel(accountManager: AppDependencyProvider.shared.accountManager, + apiService: DefaultAPIService(), + vpnMetadataCollector: DefaultVPNMetadataCollector(), + source: .settings) NavigationLink { UnifiedFeedbackCategoryView(UserText.subscriptionFeedback, sources: UnifiedFeedbackFlowCategory.self, selection: $viewModel.selectedFeedbackFlow) { if let selectedFeedbackFlow = viewModel.selectedFeedbackFlow { diff --git a/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackFormViewModel.swift b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackFormViewModel.swift index 6ecfd2aa63..ea36971224 100644 --- a/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackFormViewModel.swift +++ b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackFormViewModel.swift @@ -19,8 +19,13 @@ import Combine import SwiftUI +import Networking +import Subscription final class UnifiedFeedbackFormViewModel: ObservableObject { + private static let feedbackEndpoint = URL(string: "https://subscriptions.duckduckgo.com/api/feedback")! + private static let platform = "ios" + enum Source: String { case settings case ppro @@ -59,6 +64,32 @@ final class UnifiedFeedbackFormViewModel: ObservableObject { case reportSubmitShow } + enum Error: String, Swift.Error { + case missingAccessToken + case invalidRequest + case invalidResponse + } + + struct Payload: Codable { + let userEmail: String + let feedbackSource: String + let platform: String + let problemCategory: String + + let feedbackText: String + let problemSubCategory: String + let customMetadata: String + + func toData() -> Data? { + try? JSONEncoder().encode(self) + } + } + + struct Response: Decodable { + let message: String? + let error: String? + } + @Published var viewState: ViewState { didSet { updateSubmitButtonStatus() @@ -88,6 +119,12 @@ final class UnifiedFeedbackFormViewModel: ObservableObject { } } + @Published var userEmail = "" { + didSet { + updateSubmitButtonStatus() + } + } + var usesCompactForm: Bool { guard let selectedReportType else { return false } switch UnifiedFeedbackReportType(rawValue: selectedReportType) { @@ -98,18 +135,24 @@ final class UnifiedFeedbackFormViewModel: ObservableObject { } } + private let accountManager: any AccountManager + private let apiService: any Networking.APIService private let vpnMetadataCollector: any UnifiedMetadataCollector private let defaultMetadataCollector: any UnifiedMetadataCollector private let feedbackSender: any UnifiedFeedbackSender let source: String - init(vpnMetadataCollector: any UnifiedMetadataCollector, + init(accountManager: any AccountManager, + apiService: any Networking.APIService, + vpnMetadataCollector: any UnifiedMetadataCollector, defaultMetadatCollector: any UnifiedMetadataCollector = DefaultMetadataCollector(), feedbackSender: any UnifiedFeedbackSender = DefaultFeedbackSender(), source: Source = .unknown) { self.viewState = .feedbackPending + self.accountManager = accountManager + self.apiService = apiService self.vpnMetadataCollector = vpnMetadataCollector self.defaultMetadataCollector = defaultMetadatCollector self.feedbackSender = feedbackSender @@ -206,6 +249,7 @@ final class UnifiedFeedbackFormViewModel: ObservableObject { switch UnifiedFeedbackCategory(rawValue: selectedCategory) { case .vpn: let metadata = await vpnMetadataCollector.collectMetadata() + try await submitIssue(metadata: metadata) try await feedbackSender.sendReportIssuePixel(source: source, category: selectedCategory, subcategory: selectedSubcategory, @@ -213,6 +257,7 @@ final class UnifiedFeedbackFormViewModel: ObservableObject { metadata: metadata as? VPNMetadata) default: let metadata = await defaultMetadataCollector.collectMetadata() + try await submitIssue(metadata: metadata) try await feedbackSender.sendReportIssuePixel(source: source, category: selectedCategory, subcategory: selectedSubcategory, @@ -221,8 +266,40 @@ final class UnifiedFeedbackFormViewModel: ObservableObject { } } + private func submitIssue(metadata: UnifiedFeedbackMetadata?) async throws { + guard !userEmail.isEmpty, let selectedCategory else { return } + + guard let accessToken = accountManager.accessToken else { + throw Error.missingAccessToken + } + + let payload = Payload(userEmail: userEmail, + feedbackSource: source, + platform: Self.platform, + problemCategory: selectedCategory, + feedbackText: feedbackFormText, + problemSubCategory: selectedSubcategory ?? "", + customMetadata: metadata?.toString() ?? "") + let headers = APIRequestV2.HeadersV2(additionalHeaders: [HTTPHeaderKey.authorization: "Bearer \(accessToken)"]) + guard let request = APIRequestV2(url: Self.feedbackEndpoint, method: .post, headers: headers, body: payload.toData()) else { + throw Error.invalidRequest + } + + let response: Response = try await apiService.fetch(request: request).decodeBody() + if let error = response.error, !error.isEmpty { + throw Error.invalidResponse + } + } + + private func updateSubmitButtonStatus() { - self.submitButtonEnabled = viewState.canSubmit && !feedbackFormText.isEmpty + self.submitButtonEnabled = viewState.canSubmit && !feedbackFormText.isEmpty && (userEmail.isEmpty || userEmail.isValidEmail) } +} +private extension String { + var isValidEmail: Bool { + guard let regex = try? NSRegularExpression(pattern: #"[^\s]+@[^\s]+\.[^\s]+"#) else { return false } + return matches(regex) + } } diff --git a/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackRootView.swift b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackRootView.swift index 58b43f15d7..68e2184e3e 100644 --- a/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackRootView.swift +++ b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackRootView.swift @@ -246,6 +246,12 @@ private struct IssueDescriptionFormView: View { text: $viewModel.feedbackFormText, focusState: $isTextEditorFocused, scrollViewProxy: scrollView) + Text(UserText.pproFeedbackFormEmailLabel) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + TextField(UserText.pproFeedbackFormEmailPlaceholder, text: $viewModel.userEmail) + .textFieldStyle(.roundedBorder) footer() .padding(.horizontal, 4) } diff --git a/DuckDuckGo/Subscription/Feedback/UnifiedMetadataCollector.swift b/DuckDuckGo/Subscription/Feedback/UnifiedMetadataCollector.swift index e6a0c25161..a3c23e870f 100644 --- a/DuckDuckGo/Subscription/Feedback/UnifiedMetadataCollector.swift +++ b/DuckDuckGo/Subscription/Feedback/UnifiedMetadataCollector.swift @@ -27,6 +27,7 @@ protocol UnifiedMetadataCollector { protocol UnifiedFeedbackMetadata: Encodable { func toBase64() -> String + func toString() -> String } extension UnifiedFeedbackMetadata { @@ -40,4 +41,14 @@ extension UnifiedFeedbackMetadata { return "Failed to encode metadata to JSON, error message: \(error.localizedDescription)" } } + + func toString() -> String { + let encoder = JSONEncoder() + do { + let encodedMetadata = try encoder.encode(self) + return String(data: encodedMetadata, encoding: .utf8) ?? "" + } catch { + return "Failed to encode metadata to JSON string, error message: \(error.localizedDescription)" + } + } } diff --git a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift index 82c7b3f04b..a06e664b76 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift @@ -21,6 +21,7 @@ import Foundation import SwiftUI import DesignResourcesKit import Core +import Networking struct SubscriptionSettingsView: View { @@ -243,7 +244,12 @@ struct SubscriptionSettingsView: View { @ViewBuilder private var supportButton: some View { - let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: DefaultVPNMetadataCollector(), source: .ppro) + let viewModel = UnifiedFeedbackFormViewModel( + accountManager: AppDependencyProvider.shared.accountManager, + apiService: DefaultAPIService(), + vpnMetadataCollector: DefaultVPNMetadataCollector(), + source: .ppro + ) NavigationLink(UserText.subscriptionFeedback, destination: UnifiedFeedbackRootView(viewModel: viewModel)) .daxBodyRegular() .foregroundColor(.init(designSystemColor: .textPrimary)) diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 43e48a8762..1ef7583a94 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -592,6 +592,9 @@ public struct UserText { static let pproFeedbackFormGeneralFeedbackPlaceholder = NSLocalizedString("ppro.feedback-form.general-feedback.placeholder", value: "Please give us your feedback…", comment: "Placeholder for the General Feedback step in the Privacy Pro feedback form") static let pproFeedbackFormRequestFeaturePlaceholder = NSLocalizedString("ppro.feedback-form.request-feature.placeholder", value: "What feature would you like to see?", comment: "Placeholder for the Feature Request step in the Privacy Pro feedback form") + static let pproFeedbackFormEmailLabel = NSLocalizedString("ppro.feedback-form.email.label", value: "Provide an email if you’d like us to contact you about this issue (we may not be able to respond to all issues):", comment: "Label for the email form in the Privacy Pro feedback form") + static let pproFeedbackFormEmailPlaceholder = NSLocalizedString("ppro.feedback-form.email.placeholder", value: "Email (optional)", comment: "Placeholder for the email form in the Privacy Pro feedback form") + static let pproFeedbackFormText1 = NSLocalizedString("ppro.feedback-form.text-1", value: "Found an issue not covered in our [help center](duck://)? We definitely want to know about it.", comment: "Text for the body of the PPro feedback form") static let pproFeedbackFormText2 = NSLocalizedString("ppro.feedback-form.text-2", value: "In addition to the details entered above, we send some anonymized info with your feedback:", comment: "Text for the body of the PPro feedback form") static let pproFeedbackFormText3 = NSLocalizedString("ppro.feedback-form.text-3", value: "• Whether some browser features are active", comment: "Bullet text for the body of the PPro feedback form") diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index fc7d856f4a..5df6ee8de9 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -1994,6 +1994,12 @@ https://duckduckgo.com/mac"; /* Deactivate button */ "pm.deactivate" = "Deactivate"; +/* Label for the email form in the Privacy Pro feedback form */ +"ppro.feedback-form.email.label" = "Provide an email if you’d like us to contact you about this issue (we may not be able to respond to all issues):"; + +/* Placeholder for the email form in the Privacy Pro feedback form */ +"ppro.feedback-form.email.placeholder" = "Email (optional)"; + /* Placeholder for the General Feedback step in the Privacy Pro feedback form */ "ppro.feedback-form.general-feedback.placeholder" = "Please give us your feedback…";