Skip to content

Commit

Permalink
Send PPro feedback to support inbox
Browse files Browse the repository at this point in the history
  • Loading branch information
quanganhdo committed Nov 18, 2024
1 parent 52b2b79 commit e5c8f72
Show file tree
Hide file tree
Showing 8 changed files with 122 additions and 5 deletions.
6 changes: 5 additions & 1 deletion DuckDuckGo/NetworkProtectionStatusView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import SwiftUI
import NetworkProtection
import TipKit
import Networking

struct NetworkProtectionStatusView: View {

Expand Down Expand Up @@ -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()))
Expand Down
6 changes: 5 additions & 1 deletion DuckDuckGo/SettingsOthersView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import SwiftUI
import UIKit
import Networking

struct SettingsOthersView: View {

Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -206,13 +249,15 @@ 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,
description: feedbackFormText,
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,
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
11 changes: 11 additions & 0 deletions DuckDuckGo/Subscription/Feedback/UnifiedMetadataCollector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ protocol UnifiedMetadataCollector {

protocol UnifiedFeedbackMetadata: Encodable {
func toBase64() -> String
func toString() -> String
}

extension UnifiedFeedbackMetadata {
Expand All @@ -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)"
}
}
}
8 changes: 7 additions & 1 deletion DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import Foundation
import SwiftUI
import DesignResourcesKit
import Core
import Networking

struct SubscriptionSettingsView: View {

Expand Down Expand Up @@ -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))
Expand Down
3 changes: 3 additions & 0 deletions DuckDuckGo/UserText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions DuckDuckGo/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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…";

Expand Down

0 comments on commit e5c8f72

Please sign in to comment.