From 5e947621cbc26ef200cb23d1f239e604451ae1d9 Mon Sep 17 00:00:00 2001 From: Jacob Sikorski Date: Thu, 21 Sep 2023 17:41:00 -0600 Subject: [PATCH] Add `SubmitReportView` --- .../Shields/ReportBrokenSiteView.swift | 86 ------------------ .../Brave/Frontend/Shields/ShieldsView.swift | 2 +- .../Shields/ShieldsViewController.swift | 84 ++++++++++++------ .../Frontend/Shields/SubmitReportView.swift | 88 +++++++++++++++++++ Sources/BraveShields/WebcompatReporter.swift | 25 ++---- Sources/BraveStrings/BraveStrings.swift | 31 ++++++- .../Views/BraveTextFieldStyle.swift | 46 ++++++++++ 7 files changed, 226 insertions(+), 136 deletions(-) delete mode 100644 Sources/Brave/Frontend/Shields/ReportBrokenSiteView.swift create mode 100644 Sources/Brave/Frontend/Shields/SubmitReportView.swift diff --git a/Sources/Brave/Frontend/Shields/ReportBrokenSiteView.swift b/Sources/Brave/Frontend/Shields/ReportBrokenSiteView.swift deleted file mode 100644 index ef891189a1f..00000000000 --- a/Sources/Brave/Frontend/Shields/ReportBrokenSiteView.swift +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright 2020 The Brave Authors. All rights reserved. -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -import UIKit -import Shared -import BraveShared -import BraveUI - -class ReportBrokenSiteView: UIStackView { - - private let titleLabel = UILabel().then { - $0.text = Strings.Shields.reportABrokenSite - $0.textColor = .braveLabel - $0.font = .systemFont(ofSize: 24.0) - $0.numberOfLines = 0 - } - - private let bodyLabelOne = UILabel().then { - $0.text = Strings.Shields.reportBrokenSiteBody1 - $0.textColor = .braveLabel - $0.font = .systemFont(ofSize: 16.0) - $0.numberOfLines = 0 - } - - private let bodyLabelTwo = UILabel().then { - $0.text = Strings.Shields.reportBrokenSiteBody2 - $0.textColor = .braveLabel - $0.font = .systemFont(ofSize: 16.0) - $0.numberOfLines = 0 - } - - let urlLabel = UILabel().then { - $0.textColor = .braveBlurpleTint - $0.font = .systemFont(ofSize: 16.0) - $0.numberOfLines = 0 - } - - let cancelButton = ActionButton(type: .system).then { - $0.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) - $0.titleEdgeInsets = UIEdgeInsets(top: 4, left: 20, bottom: 4, right: 20) - $0.setTitle(Strings.cancelButtonTitle, for: .normal) - $0.tintColor = .braveLabel - } - - let submitButton = ActionButton(type: .system).then { - $0.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) - $0.titleEdgeInsets = UIEdgeInsets(top: 4, left: 20, bottom: 4, right: 20) - $0.backgroundColor = .braveBlurpleTint - $0.setTitleColor(.white, for: .normal) - $0.setTitle(Strings.Shields.reportBrokenSubmitButtonTitle, for: .normal) - $0.layer.borderWidth = 0 - } - - override init(frame: CGRect) { - super.init(frame: frame) - - axis = .vertical - layoutMargins = UIEdgeInsets(equalInset: 30) - isLayoutMarginsRelativeArrangement = true - spacing = 12 - - addStackViewItems( - .view(titleLabel), - .customSpace(16), - .view(bodyLabelOne), - .view(urlLabel), - .view(bodyLabelTwo), - .view( - UIStackView().then { - $0.spacing = 10 - $0.addStackViewItems( - .view(UIView()), // spacer - .view(cancelButton), - .view(submitButton) - ) - }) - ) - } - - @available(*, unavailable) - required init(coder: NSCoder) { - fatalError() - } -} diff --git a/Sources/Brave/Frontend/Shields/ShieldsView.swift b/Sources/Brave/Frontend/Shields/ShieldsView.swift index 95559b4c7bc..67dac7e5250 100644 --- a/Sources/Brave/Frontend/Shields/ShieldsView.swift +++ b/Sources/Brave/Frontend/Shields/ShieldsView.swift @@ -6,6 +6,7 @@ import Foundation import Shared import BraveShared import UIKit +import SwiftUI extension ShieldsViewController { class View: UIView { @@ -38,7 +39,6 @@ extension ShieldsViewController { $0.isHidden = true } - let reportBrokenSiteView = ReportBrokenSiteView() let siteReportedView = SiteReportedView() override init(frame: CGRect) { diff --git a/Sources/Brave/Frontend/Shields/ShieldsViewController.swift b/Sources/Brave/Frontend/Shields/ShieldsViewController.swift index da528a2aa89..320b32809c4 100644 --- a/Sources/Brave/Frontend/Shields/ShieldsViewController.swift +++ b/Sources/Brave/Frontend/Shields/ShieldsViewController.swift @@ -13,6 +13,7 @@ import UIKit import Growth import BraveCore import BraveVPN +import SwiftUI /// Displays shield settings and shield stats for a given URL class ShieldsViewController: UIViewController, PopoverContentComponent { @@ -191,6 +192,10 @@ class ShieldsViewController: UIViewController, PopoverContentComponent { shieldsView.contentView = view } } + + private var preferredWidth: CGFloat { + return min(360, UIScreen.main.bounds.width - 20) + } private func updatePreferredContentSize() { guard let visibleView = shieldsView.contentView else { return } @@ -208,6 +213,30 @@ class ShieldsViewController: UIViewController, PopoverContentComponent { height: height ) } + + private lazy var reportViewController: UIHostingController? = { + guard let url = url else { return nil } + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.fragment = nil + components?.queryItems = nil + guard let cleanedURL = components?.url else { return nil } + + let viewController = UIHostingController(rootView: SubmitReportView( + width: preferredWidth, url: cleanedURL, + dismiss: tappedCancelReportingButton, + submit: { additionalDetails, contactInfo in + self.tappedSubmitReportingButton(url: cleanedURL, additionalDetails: additionalDetails, contactInfo: contactInfo) + } + )) + + guard let swiftUIView = viewController.view else { return nil } + swiftUIView.alpha = 0 + swiftUIView.translatesAutoresizingMaskIntoConstraints = false + addChild(viewController) + view.addSubview(swiftUIView) + viewController.didMove(toParent: self) + return viewController + }() // MARK: - @@ -239,7 +268,6 @@ class ShieldsViewController: UIViewController, PopoverContentComponent { let normalizedDisplayHost = URLFormatter.formatURLOrigin(forDisplayOmitSchemePathAndTrivialSubdomains: url?.absoluteString ?? "") shieldsView.simpleShieldView.hostLabel.text = normalizedDisplayHost - shieldsView.reportBrokenSiteView.urlLabel.text = url?.domainURL.absoluteString shieldsView.simpleShieldView.shieldsSwitch.addTarget(self, action: #selector(shieldsOverrideSwitchValueChanged), for: .valueChanged) shieldsView.advancedShieldView.siteTitle.titleLabel.text = normalizedDisplayHost.uppercased() shieldsView.advancedShieldView.globalControlsButton.addTarget(self, action: #selector(tappedGlobalShieldsButton), for: .touchUpInside) @@ -250,8 +278,6 @@ class ShieldsViewController: UIViewController, PopoverContentComponent { shieldsView.simpleShieldView.blockCountView.shareButton.addTarget(self, action: #selector(tappedShareShieldsButton), for: .touchUpInside) shieldsView.simpleShieldView.reportSiteButton.addTarget(self, action: #selector(tappedReportSiteButton), for: .touchUpInside) - shieldsView.reportBrokenSiteView.cancelButton.addTarget(self, action: #selector(tappedCancelReportingButton), for: .touchUpInside) - shieldsView.reportBrokenSiteView.submitButton.addTarget(self, action: #selector(tappedSubmitReportingButton), for: .touchUpInside) updateShieldBlockStats() @@ -317,36 +343,42 @@ class ShieldsViewController: UIViewController, PopoverContentComponent { } @objc private func tappedReportSiteButton() { - updateContentView(to: shieldsView.reportBrokenSiteView, animated: true) + guard let view = reportViewController?.view else { + assertionFailure() + return + } + + updateContentView(to: view, animated: true) } @objc private func tappedCancelReportingButton() { updateContentView(to: shieldsView.stackView, animated: true) } - @objc private func tappedSubmitReportingButton() { - if let url = url { - Task { @MainActor in - let domain = Domain.getOrCreate(forUrl: url, persistent: !tab.isPrivate) - - let report = WebcompatReporter.Report( - fullUrl: url, - areShieldsEnabled: !domain.areAllShieldsOff, - adBlockLevel: domain.blockAdsAndTrackingLevel, - fingerprintProtectionLevel: domain.finterprintProtectionLevel, - adBlockListTitles: FilterListStorage.shared.filterLists.compactMap({ filterList -> String? in - guard filterList.isEnabled else { return nil } - return filterList.entry.title - }), - isVPNEnabled: BraveVPN.isConnected - ) - - await WebcompatReporter.send(report: report) - try await Task.sleep(nanoseconds: NSEC_PER_SEC * 2) - guard !self.isBeingDismissed else { return } - self.dismiss(animated: true) - } + @objc private func tappedSubmitReportingButton(url: URL, additionalDetails: String, contactInfo: String) { + Task { @MainActor in + let domain = Domain.getOrCreate(forUrl: url, persistent: !tab.isPrivate) + + let report = WebcompatReporter.Report( + cleanedURL: url, + additionalDetails: additionalDetails, + contactInfo: contactInfo, + areShieldsEnabled: !domain.areAllShieldsOff, + adBlockLevel: domain.blockAdsAndTrackingLevel, + fingerprintProtectionLevel: domain.finterprintProtectionLevel, + adBlockListTitles: FilterListStorage.shared.filterLists.compactMap({ filterList -> String? in + guard filterList.isEnabled else { return nil } + return filterList.entry.title + }), + isVPNEnabled: BraveVPN.isConnected + ) + + await WebcompatReporter.send(report: report) + try await Task.sleep(nanoseconds: NSEC_PER_SEC * 2) + guard !self.isBeingDismissed else { return } + self.dismiss(animated: true) } + updateContentView(to: shieldsView.siteReportedView, animated: true) } diff --git a/Sources/Brave/Frontend/Shields/SubmitReportView.swift b/Sources/Brave/Frontend/Shields/SubmitReportView.swift new file mode 100644 index 00000000000..ec0681baa1c --- /dev/null +++ b/Sources/Brave/Frontend/Shields/SubmitReportView.swift @@ -0,0 +1,88 @@ +// +// SwiftUIView.swift +// +// +// Created by Jacob on 2023-09-21. +// + +import SwiftUI +import Strings +import BraveUI +import DesignSystem +import BraveShields + +struct SubmitReportView: View { + let width: CGFloat + let url: URL + let dismiss: () -> Void + let submit: (String, String) -> Void + + @State var additionalDetails = "" + @State var contactDetails = "" + + private var borderShape: some InsettableShape { + RoundedRectangle(cornerRadius: 4, style: .continuous) + } + + private var textEditor: some View { + TextEditor(text: $additionalDetails) + .modifier(BaseBraveTextEditorStyleModifier( + placeholder: Strings.Shields.reportBrokenAdditionalDetails, + isPlaceholderVisible: additionalDetails.isEmpty + )) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(Strings.Shields.reportABrokenSite) + .font(.title) + Text(Strings.Shields.reportBrokenSiteBody1) + Text(url.absoluteString) + .foregroundStyle(Color(braveSystemName: .textInteractive)) + Text(Strings.Shields.reportBrokenSiteBody2) + .font(.footnote) + + textEditor + + VStack(alignment: .leading, spacing: 4) { + Text(Strings.Shields.reportBrokenContactMe).font(.caption) + TextField( + Strings.Shields.reportBrokenContactMe, + text: $contactDetails, prompt: Text(Strings.Shields.reportBrokenContactMeSuggestions) + ) + .textFieldStyle(BraveTextFieldStyle()) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + + HStack { + Spacer() + Button(Strings.cancelButtonTitle, action: dismiss) + .buttonStyle(BraveOutlineButtonStyle(size: .large)) + .multilineTextAlignment(.center) + + Button(Strings.Shields.reportBrokenSubmitButtonTitle, action: { + submit(additionalDetails, contactDetails) + }) + .buttonStyle(BraveFilledButtonStyle(size: .large)) + .multilineTextAlignment(.center) + } + } + .padding() + .foregroundStyle(Color(braveSystemName: .textSecondary)) + } + .foregroundStyle(Color(braveSystemName: .textSecondary)) + .frame(maxWidth: width) + } +} + +#Preview { + SubmitReportView( + width: 300, + url: URL(string: "https://brave.com/privacy-features")!) { + // Do nothing + } submit: { _, _ in + // Do nothing + } +} diff --git a/Sources/BraveShields/WebcompatReporter.swift b/Sources/BraveShields/WebcompatReporter.swift index 2c0919e27e7..093637c3ef2 100644 --- a/Sources/BraveShields/WebcompatReporter.swift +++ b/Sources/BraveShields/WebcompatReporter.swift @@ -13,8 +13,8 @@ public class WebcompatReporter { /// The raw values of the web-report. public struct Report { /// The URL of the broken site. - /// - Note: This is the full url and will be used to extract all relevant information - let fullUrl: URL + /// - Note: This needs to be the cleaned up version with query params and fragments removed (as seen in the UI) + let cleanedURL: URL /// Any user input details let additionalDetails: String? /// Any user input contact details that may be provided @@ -31,22 +31,15 @@ public class WebcompatReporter { let isVPNEnabled: Bool var domain: String? { - return fullUrl.normalizedHost() != nil ? fullUrl.domainURL.absoluteString : fullUrl.baseDomain - } - - var cleanedURL: URL? { - var components = URLComponents(url: fullUrl, resolvingAgainstBaseURL: false) - components?.fragment = nil - components?.queryItems = nil - return components?.url + return cleanedURL.normalizedHost() != nil ? cleanedURL.domainURL.absoluteString : cleanedURL.baseDomain } public init( - fullUrl: URL, additionalDetails: String? = nil, contactInfo: String? = nil, + cleanedURL: URL, additionalDetails: String? = nil, contactInfo: String? = nil, areShieldsEnabled: Bool, adBlockLevel: ShieldLevel, fingerprintProtectionLevel: ShieldLevel, adBlockListTitles: [String], isVPNEnabled: Bool ) { - self.fullUrl = fullUrl + self.cleanedURL = cleanedURL self.additionalDetails = additionalDetails self.contactInfo = contactInfo self.areShieldsEnabled = areShieldsEnabled @@ -94,15 +87,9 @@ public class WebcompatReporter { )) } - guard let cleanedURL = report.cleanedURL else { - throw EncodingError.invalidValue(CodingKeys.domain, EncodingError.Context( - codingPath: encoder.codingPath, debugDescription: "Cannot strip fragments or query params" - )) - } - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - try container.encode(cleanedURL.absoluteString, forKey: .url) try container.encode(domain, forKey: .domain) + try container.encode(report.cleanedURL.absoluteString, forKey: .url) try container.encodeIfPresent(report.additionalDetails, forKey: .additionalDetails) try container.encodeIfPresent(report.contactInfo, forKey: .contactInfo) try container.encodeIfPresent(languageCode, forKey: .languages) diff --git a/Sources/BraveStrings/BraveStrings.swift b/Sources/BraveStrings/BraveStrings.swift index 7235ec09c9b..8fe1daeda50 100644 --- a/Sources/BraveStrings/BraveStrings.swift +++ b/Sources/BraveStrings/BraveStrings.swift @@ -1410,14 +1410,37 @@ extension Strings { public static let aboutBraveShieldsTitle = NSLocalizedString("AboutBraveShields", tableName: "BraveShared", bundle: .module, value: "About Brave Shields", comment: "The title of the screen explaining Brave Shields") public static let aboutBraveShieldsBody = NSLocalizedString("AboutBraveShieldsBody", tableName: "BraveShared", bundle: .module, value: "Sites often include cookies and scripts which try to identify you and your device. They want to work out who you are and follow you across the web — tracking what you do on every site.\n\nBrave blocks these things so that you can browse without being followed around.", comment: "The body of the screen explaining Brave Shields") public static let shieldsDownDisclaimer = NSLocalizedString("ShieldsDownDisclaimer", tableName: "BraveShared", bundle: .module, value: "You're browsing this site without Brave's privacy protections. Does it not work right with Shields up?", comment: "") - public static let reportABrokenSite = NSLocalizedString("ReportABrokenSite", tableName: "BraveShared", bundle: .module, value: "Report a broken site", comment: "") - public static let reportBrokenSiteBody1 = NSLocalizedString("ReportBrokenSiteBody1", tableName: "BraveShared", bundle: .module, value: "Let Brave's developers know that this site doesn't work properly with Shields:", comment: "First part of the report a broken site copy. After the colon is a new line and then a website address") - public static let reportBrokenSiteBody2 = NSLocalizedString("ReportBrokenSiteBody2", tableName: "BraveShared", bundle: .module, value: "Note: This site address will be submitted with your Brave version number and your IP address (which will not be stored).", comment: "") - public static let reportBrokenSubmitButtonTitle = NSLocalizedString("ReportBrokenSubmitButtonTitle", tableName: "BraveShared", bundle: .module, value: "Submit", comment: "") public static let globalControls = NSLocalizedString("BraveShieldsGlobalControls", tableName: "BraveShared", bundle: .module, value: "Global Controls", comment: "") public static let globalChangeButton = NSLocalizedString("BraveShieldsGlobalChangeButton", tableName: "BraveShared", bundle: .module, value: "Change Shields Global Defaults", comment: "") public static let siteReportedTitle = NSLocalizedString("SiteReportedTitle", tableName: "BraveShared", bundle: .module, value: "Thank You", comment: "") public static let siteReportedBody = NSLocalizedString("SiteReportedBody", tableName: "BraveShared", bundle: .module, value: "Thanks for letting Brave's developers know that there's something wrong with this site. We'll do our best to fix it!", comment: "") + + + // MARK: Submit report + public static let reportABrokenSite = NSLocalizedString("ReportABrokenSite", tableName: "BraveShared", bundle: .module, value: "Report a broken site", comment: "") + public static let reportBrokenSiteBody1 = NSLocalizedString("ReportBrokenSiteBody1", tableName: "BraveShared", bundle: .module, value: "Let Brave's developers know that this site doesn't work properly with Shields:", comment: "First part of the report a broken site copy. After the colon is a new line and then a website address") + public static let reportBrokenSiteBody2 = NSLocalizedString("ReportBrokenSiteBody2", tableName: "BraveShared", bundle: .module, value: "Note: The report sent to Brave servers will include the site address, Brave version number, Shields settings, VPN status, and language settings. The IP address will be transmitted but not stored.", comment: "This is the info text that is presented when a user is submitting a web-compatibility report.") + public static let reportBrokenSubmitButtonTitle = NSLocalizedString("ReportBrokenSubmitButtonTitle", tableName: "BraveShared", bundle: .module, value: "Submit", comment: "") + + /// A label for a text entry field where the user can provide additional details for a web-compatibility report + public static let reportBrokenAdditionalDetails = NSLocalizedString( + "ReportBrokenAdditionalDetails", tableName: "BraveShared", bundle: .module, + value: "Additional details (optional)", + comment: "A label for a text entry field where the user can provide additional details for a web-compatibility report" + ) + + public static let reportBrokenContactMe = NSLocalizedString( + "ReportBrokenAdditionalDetails", tableName: "BraveShared", bundle: .module, + value: "Contact me at: (optional)", + comment: "A label for a text entry field where the user can provide contact details within a web-compatibilty report" + ) + + + public static let reportBrokenContactMeSuggestions = NSLocalizedString( + "ReportBrokenAdditionalDetails", tableName: "BraveShared", bundle: .module, + value: "Email, Twitter, etc.", + comment: "A placeholder for a text entry field within a web-compatibilty report which shows a few suggestions of what the user should enter for their contact contact details (in a 'Contact me at: (optional)' field)." + ) } } diff --git a/Sources/DesignSystem/Views/BraveTextFieldStyle.swift b/Sources/DesignSystem/Views/BraveTextFieldStyle.swift index 32a1d413251..75d7d6783b0 100644 --- a/Sources/DesignSystem/Views/BraveTextFieldStyle.swift +++ b/Sources/DesignSystem/Views/BraveTextFieldStyle.swift @@ -110,3 +110,49 @@ private struct BaseBraveTextFieldStyleModifier: ViewModifier { .clipShape(borderShape) } } + +public struct BaseBraveTextEditorStyleModifier: ViewModifier { + @Environment(\.pixelLength) private var pixelLength + + private var placeholder: String + private var isPlaceholderVisible: Bool + private var strokeColor: Color? + private var lineWidthFactor: CGFloat? + + public init(placeholder: String, isPlaceholderVisible: Bool, strokeColor: Color? = nil, lineWidthFactor: CGFloat? = nil) { + self.placeholder = placeholder + self.isPlaceholderVisible = isPlaceholderVisible + self.strokeColor = strokeColor + self.lineWidthFactor = lineWidthFactor + } + + private var borderShape: some InsettableShape { + RoundedRectangle(cornerRadius: 4, style: .continuous) + } + + public func body(content: Content) -> some View { + content + .frame(height: 140) + .font(.callout) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background( + borderShape + .stroke(strokeColor ?? Color(.secondaryButtonTint), lineWidth: 2 * (lineWidthFactor ?? 1)) + ) + .overlay( + Text(placeholder) + .disabled(true) + .allowsHitTesting(false) + .font(.callout) + .padding(.vertical, 10) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundColor(Color(.placeholderText)) + .opacity(isPlaceholderVisible ? 1 : 0) + .accessibilityHidden(true), + alignment: .top + ) + .clipShape(borderShape) + } +}