Skip to content

Commit

Permalink
Send PPro feedback to support inbox (#3591)
Browse files Browse the repository at this point in the history
<!--
Note: This checklist is a reminder of our shared engineering
expectations. Feel free to change it, although assigning a GitHub
reviewer and the items in bold are required.

⚠️ If you're an external contributor, please file an issue first before
working on a PR, as we can't guarantee that we will accept your changes
if they haven't been discussed ahead of time. Thanks!
-->

Task/Issue URL:
https://app.asana.com/0/1200019156869587/1207999338171707/f
Tech Design URL:
CC:

**Description**:

Integrates with backend to submit issue reports to support inbox.

<!--
If at any point it isn't actively being worked on/ready for
review/otherwise moving forward strongly consider closing it (or not
opening it in the first place). If you decide not to close it, use Draft
PR while work is still in progress or use `DO NOT MERGE` label to
clarify the PRs state and comment with more information.
-->

**Steps to test this PR**:
1. Go through the unified feedback flow
2. When choosing to submit an issue, there'll be a new optional email
field
3. Filling in the form with a valid email would trigger the backend
integration

<!--
Before submitting a PR, please ensure you have tested the combinations
you expect the reviewer to test, then delete configurations you *know*
do not need explicit testing.

Using a simulator where a physical device is unavailable is acceptable.
-->

**Definition of Done (Internal Only)**:

* [ ] Does this PR satisfy our [Definition of
Done](https://app.asana.com/0/1202500774821704/1207634633537039/f)?

**Copy Testing**:

* [ ] Use of correct apostrophes in new copy, ie `’` rather than `'`

**Orientation Testing**:

* [ ] Portrait
* [ ] Landscape

**Device Testing**:

* [ ] iPhone SE (1st Gen)
* [ ] iPhone 8
* [ ] iPhone X
* [ ] iPhone 14 Pro
* [ ] iPad

**OS Testing**:

* [ ] iOS 15
* [ ] iOS 16
* [ ] iOS 17

**Theme Testing**:

* [ ] Light theme
* [ ] Dark theme

---
###### Internal references:
[Software Engineering
Expectations](https://app.asana.com/0/59792373528535/199064865822552)
[Technical Design
Template](https://app.asana.com/0/59792373528535/184709971311943)
  • Loading branch information
quanganhdo authored Nov 26, 2024
1 parent e8b8322 commit f9deb90
Show file tree
Hide file tree
Showing 8 changed files with 160 additions and 14 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)
}
}
49 changes: 42 additions & 7 deletions DuckDuckGo/Subscription/Feedback/UnifiedFeedbackRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ struct UnifiedFeedbackCategoryView<Category: FeedbackCategoryProviding, Destinat
}
} header: {
Text(prompt)
.font(.caption)
}
}
.listRowBackground(Color(designSystemColor: .surface))
Expand Down Expand Up @@ -219,6 +220,7 @@ private struct CompactIssueDescriptionFormView: View {
private struct IssueDescriptionFormView: View {
@ObservedObject var viewModel: UnifiedFeedbackFormViewModel
@FocusState private var isTextEditorFocused: Bool
@Environment(\.colorScheme) private var colorScheme

let placeholder: String

Expand All @@ -233,21 +235,47 @@ private struct IssueDescriptionFormView: View {
}
}

private var textFieldBackgroundColor: Color {
colorScheme == .light ? Color(designSystemColor: .surface) : Color(uiColor: UIColor(hex: "1C1C1E"))
}

@ViewBuilder
private func form() -> some View {
ScrollView {
ScrollViewReader { scrollView in
VStack {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 0) {
header()
.padding(.horizontal, 4)
.padding(.horizontal, 16)
.padding(.vertical, 10)
IssueDescriptionTextEditor(label: UserText.pproFeedbackFormTextBoxTitle,
placeholder: placeholder,
text: $viewModel.feedbackFormText,
focusState: $isTextEditorFocused,
scrollViewProxy: scrollView)
.padding(.bottom, 10)
Text(UserText.pproFeedbackFormEmailLabel)
.font(.caption)
.textCase(.uppercase)
.foregroundColor(.secondary)
.padding(.horizontal, 16)
.padding(.bottom, 4)
TextField(UserText.pproFeedbackFormEmailPlaceholder, text: $viewModel.userEmail)
.font(.body)
.foregroundColor(.primary)
.padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.clipShape(RoundedRectangle(cornerRadius: 8.0, style: .continuous))
.background(
ZStack {
RoundedRectangle(cornerRadius: 8.0)
.stroke(textFieldBackgroundColor, lineWidth: 0.4)
RoundedRectangle(cornerRadius: 8.0)
.fill(textFieldBackgroundColor)
}
)
footer()
.padding(.horizontal, 4)
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
.foregroundColor(.secondary)
.background(Color(designSystemColor: .background))
Expand Down Expand Up @@ -321,23 +349,29 @@ private struct IssueDescriptionFormView: View {
}

private struct IssueDescriptionTextEditor: View {
@Environment(\.colorScheme) private var colorScheme

let label: String
let placeholder: String
let text: Binding<String>
let focusState: FocusState<Bool>.Binding
let scrollViewProxy: ScrollViewProxy

private var editorBackgroundColor: Color {
colorScheme == .light ? Color(designSystemColor: .surface) : Color(uiColor: UIColor(hex: "1C1C1E"))
}

var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.caption)
.textCase(.uppercase)
.foregroundColor(.secondary)
.padding(.horizontal, 4)
.padding(.horizontal, 16)
TextEditorWithPlaceholder(text: text, placeholder: placeholder)
.font(.body)
.foregroundColor(.primary)
.background(Color(designSystemColor: .panel))
.background(editorBackgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 8.0, style: .continuous))
.frame(height: 100)
.fixedSize(horizontal: false, vertical: true)
Expand Down Expand Up @@ -383,12 +417,13 @@ private struct TextEditorWithPlaceholder: View {
var body: some View {
ZStack(alignment: .topLeading) {
TextEditor(text: text)
.padding(.horizontal, 12)
if text.wrappedValue.isEmpty {
Text(placeholder)
.foregroundColor(.secondary)
.opacity(0.5)
.padding(.top, 8)
.padding(.leading, 5)
.padding(.top, 10)
.padding(.leading, 16)
}
}
}
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
5 changes: 4 additions & 1 deletion DuckDuckGo/UserText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,10 @@ 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 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 pproFeedbackFormEmailLabel = NSLocalizedString("ppro.feedback-form.email.label", value: "Email (optional)", comment: "Label for the email form in the Privacy Pro feedback form")
static let pproFeedbackFormEmailPlaceholder = NSLocalizedString("ppro.feedback-form.email.placeholder", value: "[email protected]", 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.\n\nProvide an email if you’d like us to contact you about this issue (we may not be able to respond to all issues):", comment: "Text for the body of the PPro feedback form")
static let pproFeedbackFormText2 = NSLocalizedString("ppro.feedback-form.text-2", value: "In addition to the details entered above, we send some anonymized info with your feedback:", comment: "Text for the body of the PPro feedback form")
static let pproFeedbackFormText3 = NSLocalizedString("ppro.feedback-form.text-3", value: "• Whether some browser features are active", comment: "Bullet text for the body of the PPro feedback form")
static let pproFeedbackFormText4 = NSLocalizedString("ppro.feedback-form.text-4", value: "• Aggregate app diagnostics (e.g., error codes)", comment: "Bullet text for the body of the PPro feedback form")
Expand Down
8 changes: 7 additions & 1 deletion 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" = "Email (optional)";

/* Placeholder for the email form in the Privacy Pro feedback form */
"ppro.feedback-form.email.placeholder" = "[email protected]";

/* 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 Expand Up @@ -2028,7 +2034,7 @@ https://duckduckgo.com/mac";
"ppro.feedback-form.select-category.title" = "Select a category";

/* Text for the body of the PPro feedback form */
"ppro.feedback-form.text-1" = "Found an issue not covered in our [help center](duck://)? We definitely want to know about it.";
"ppro.feedback-form.text-1" = "Found an issue not covered in our [help center](duck://)? We definitely want to know about it.\n\nProvide an email if you’d like us to contact you about this issue (we may not be able to respond to all issues):";

/* Text for the body of the PPro feedback form */
"ppro.feedback-form.text-2" = "In addition to the details entered above, we send some anonymized info with your feedback:";
Expand Down

0 comments on commit f9deb90

Please sign in to comment.