Skip to content

Commit

Permalink
Send PPro feedback to support inbox (#3567)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1200019156869587/1207999338171708/f
Tech Design URL:
CC:

**Description**:

Integrates with backend to submit issue reports to support inbox.

**Optional E2E tests**:
- [ ] Run PIR E2E tests
Check this to run the Personal Information Removal end to end tests. If
updating CCF, or any PIR related code, tick this.

**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

<!--
Tagging instructions
If this PR isn't ready to be merged for whatever reason it should be
marked with the `DO NOT MERGE` label (particularly if it's a draft)
If it's pending Product Review/PFR, please add the `Pending Product
Review` label.

If at any point it isn't actively being worked on/ready for
review/otherwise moving forward (besides the above PR/PFR exception)
strongly consider closing it (or not opening it in the first place). If
you decide not to close it, make sure it's labelled to make it clear the
PRs state and comment with more information.
-->

**Definition of Done**:

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

---
###### Internal references:
[Pull Request Review
Checklist](https://app.asana.com/0/1202500774821704/1203764234894239/f)
[Software Engineering
Expectations](https://app.asana.com/0/59792373528535/199064865822552)
[Technical Design
Template](https://app.asana.com/0/59792373528535/184709971311943)
[Pull Request
Documentation](https://app.asana.com/0/1202500774821704/1204012835277482/f)
  • Loading branch information
quanganhdo authored Nov 26, 2024
1 parent 86fccda commit 6c9724c
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ extension UserText {
// "general.feedback-form.category.vpn" - Description for the feedback form when the issue is related to VPN
static let generalFeedbackFormCategoryVPN = "VPN"
// "general.feedback-form.category.pir" - Description for the feedback form when the issue is related to Personal Info Removal (PIR)
static let generalFeedbackFormCategoryPIR = "Personal Info Removal"
static let generalFeedbackFormCategoryPIR = "Personal Information Removal"
// "general.feedback-form.category.itr" - Description for the feedback form when the issue is related to Identity Theft Restoration (ITR)
static let generalFeedbackFormCategoryITR = "Identity Theft Restoration"
// "ppro.feedback-form.category.select-category" - Title for the category selection state of the feedback form
Expand Down Expand Up @@ -145,6 +145,11 @@ extension UserText {
// "ppro.feedback-form.disclaimer" - Text for the disclaimer of the PPro feedback form
static let pproFeedbackFormDisclaimer = "Reports are anonymous and sent to DuckDuckGo to help improve our service"

// "ppro.feedback-form.email.label" - Label for the email field of the PPro feedback form
static let pproFeedbackFormEmailLabel = "Provide an email if you’d like us to contact you about this issue (we may not be able to respond to all issues):"
// "ppro.feedback-form.email.placeholder" - Placeholder for the email field of the PPro feedback form
static let pproFeedbackFormEmailPlaceholder = "Email (optional)"

// "ppro.feedback-form.sending-confirmation.title" - Title for the feedback sent view title of the feedback form
static let pproFeedbackFormSendingConfirmationTitle = "Thank you!"
// "ppro.feedback-form.sending-confirmation.description" - Title for the feedback sent view description of the feedback form
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ protocol UnifiedMetadataCollector {

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

extension UnifiedFeedbackMetadata {
Expand All @@ -39,4 +40,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)"
}
}
}
7 changes: 7 additions & 0 deletions DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackFormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,13 @@ private struct FeedbackFormBodyView: View {
await viewModel.process(action: .faqClick)
}
}
} content: {
Text(UserText.pproFeedbackFormEmailLabel)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
TextField(UserText.pproFeedbackFormEmailPlaceholder, text: $viewModel.userEmail)
.textFieldStyle(.roundedBorder)
} footer: {
Text(UserText.pproFeedbackFormText2)
VStack(alignment: .leading) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,19 @@ import AppKit
import SwiftUI
import Combine
import PixelKit
import Subscription
import Networking

final class UnifiedFeedbackFormViewController: NSViewController {
// Using a dynamic height in the form was causing layout problems and couldn't be completed in time for the release that needed this form.
// As a temporary measure, the heights of each form state are hardcoded.
// This should be cleaned up later, and eventually use the `sizingOptions` property of NSHostingController.
enum Constants {
static let landingPageHeight = 260.0
static let feedbackFormMiniHeight = 350.0
static let feedbackFormCompactHeight = 430.0
static let feedbackFormHeight = 650.0
static let feedbackFormHeight = 740.0
static let feedbackSentHeight = 350.0
static let feedbackErrorHeight = 560.0
}

private let defaultSize = CGSize(width: 480, height: Constants.landingPageHeight)
Expand All @@ -46,6 +48,8 @@ final class UnifiedFeedbackFormViewController: NSViewController {
source: UnifiedFeedbackSource = .default) {
self.feedbackSender = feedbackSender
self.viewModel = UnifiedFeedbackFormViewModel(
accountManager: DefaultSubscriptionManager().accountManager,
apiService: DefaultAPIService(),
vpnMetadataCollector: DefaultVPNMetadataCollector(accountManager: Application.appDelegate.subscriptionManager.accountManager),
feedbackSender: feedbackSender,
source: source
Expand Down Expand Up @@ -90,13 +94,17 @@ final class UnifiedFeedbackFormViewController: NSViewController {
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.updateViewHeight()
}
.store(in: &cancellables)
}
.store(in: &cancellables)

viewModel.$selectedReportType
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.updateViewHeight()
Publishers.MergeMany(
viewModel.$selectedReportType,
viewModel.$selectedCategory,
viewModel.$selectedSubcategory
)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.updateViewHeight()
}
.store(in: &cancellables)
}
Expand All @@ -106,6 +114,10 @@ final class UnifiedFeedbackFormViewController: NSViewController {
case .feedbackPending:
if UnifiedFeedbackReportType(rawValue: viewModel.selectedReportType) == .prompt {
heightConstraint?.constant = Constants.landingPageHeight
} else if UnifiedFeedbackReportType(rawValue: viewModel.selectedReportType) == .reportIssue,
UnifiedFeedbackCategory(rawValue: viewModel.selectedCategory) == .prompt ||
viewModel.selectedSubcategory == PrivacyProFeedbackSubcategory.prompt.rawValue {
heightConstraint?.constant = Constants.feedbackFormMiniHeight
} else {
heightConstraint?.constant = viewModel.usesCompactForm ? Constants.feedbackFormCompactHeight : Constants.feedbackFormHeight
}
Expand All @@ -114,7 +126,7 @@ final class UnifiedFeedbackFormViewController: NSViewController {
case .feedbackSent:
heightConstraint?.constant = Constants.feedbackSentHeight
case .feedbackSendingFailed:
heightConstraint?.constant = Constants.feedbackErrorHeight
heightConstraint?.constant = (viewModel.usesCompactForm ? Constants.feedbackFormCompactHeight : Constants.feedbackFormHeight) + 20.0
}
}

Expand Down
81 changes: 79 additions & 2 deletions DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackFormViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,17 @@ import Foundation
import Combine
import SwiftUI
import PixelKit
import Networking
import Subscription

protocol UnifiedFeedbackFormViewModelDelegate: AnyObject {
func feedbackViewModelDismissedView(_ viewModel: UnifiedFeedbackFormViewModel)
}

final class UnifiedFeedbackFormViewModel: ObservableObject {
private static let feedbackEndpoint = URL(string: "https://subscriptions.duckduckgo.com/api/feedback")!
private static let platform = "macos"

enum ViewState {
case feedbackPending
case feedbackSending
Expand All @@ -42,6 +47,12 @@ final class UnifiedFeedbackFormViewModel: ObservableObject {
}
}

enum Error: String, Swift.Error {
case missingAccessToken
case invalidRequest
case invalidResponse
}

enum ViewAction {
case cancel
case submit
Expand All @@ -51,6 +62,26 @@ final class UnifiedFeedbackFormViewModel: ObservableObject {
case reportFAQClick
}

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: Codable {
let message: String?
let error: String?
}

@Published var viewState: ViewState {
didSet {
updateSubmitButtonStatus()
Expand Down Expand Up @@ -90,6 +121,12 @@ final class UnifiedFeedbackFormViewModel: ObservableObject {
}
}

@Published var userEmail = "" {
didSet {
updateSubmitButtonStatus()
}
}

private var selectedSubcategoryPrompt: String {
switch UnifiedFeedbackCategory(rawValue: selectedCategory) {
case .selectFeature, nil: return ""
Expand All @@ -113,18 +150,24 @@ final class UnifiedFeedbackFormViewModel: ObservableObject {

weak var delegate: UnifiedFeedbackFormViewModelDelegate?

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: UnifiedFeedbackSource

init(vpnMetadataCollector: any UnifiedMetadataCollector,
init(accountManager: any AccountManager,
apiService: any Networking.APIService,
vpnMetadataCollector: any UnifiedMetadataCollector,
defaultMetadataCollector: any UnifiedMetadataCollector = EmptyMetadataCollector(),
feedbackSender: any UnifiedFeedbackSender = DefaultFeedbackSender(),
source: UnifiedFeedbackSource = .default) {
self.viewState = .feedbackPending

self.accountManager = accountManager
self.apiService = apiService
self.vpnMetadataCollector = vpnMetadataCollector
self.defaultMetadataCollector = defaultMetadataCollector
self.feedbackSender = feedbackSender
Expand Down Expand Up @@ -204,13 +247,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 @@ -219,8 +264,33 @@ final class UnifiedFeedbackFormViewModel: ObservableObject {
}
}

private func submitIssue(metadata: UnifiedFeedbackMetadata?) async throws {
guard !userEmail.isEmpty else { return }

guard let accessToken = accountManager.accessToken else {
throw Error.missingAccessToken
}

let payload = Payload(userEmail: userEmail,
feedbackSource: source.rawValue,
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 func updateSubmitShowStatus() {
Expand All @@ -236,3 +306,10 @@ final class UnifiedFeedbackFormViewModel: ObservableObject {
}()
}
}

private extension String {
var isValidEmail: Bool {
guard let regex = try? NSRegularExpression(pattern: #"[^\s]+@[^\s]+\.[^\s]+"#) else { return false }
return matches(regex)
}
}
Loading

0 comments on commit 6c9724c

Please sign in to comment.