diff options
| author | Jon Petersson <jon.petersson@mullvad.net> | 2025-04-25 08:11:02 +0200 |
|---|---|---|
| committer | Jon Petersson <jon.petersson@mullvad.net> | 2025-05-30 11:32:54 +0200 |
| commit | 83effc10b5cb3f26ca1c65d30ae62baf10f495d3 (patch) | |
| tree | 59b470da0d74cf1311ccd53787df47ccdca854d4 | |
| parent | 42b53b4bcd3d3155d42ebca7e33c9450ca5dab73 (diff) | |
| download | mullvadvpn-83effc10b5cb3f26ca1c65d30ae62baf10f495d3.tar.xz mullvadvpn-83effc10b5cb3f26ca1c65d30ae62baf10f495d3.zip | |
Let users cancel sending a problem report
18 files changed, 270 insertions, 115 deletions
diff --git a/ios/MullvadMockData/MullvadREST/MockRelayCache.swift b/ios/MullvadMockData/MullvadREST/MockRelayCache.swift index f2b0123b73..d41173d111 100644 --- a/ios/MullvadMockData/MullvadREST/MockRelayCache.swift +++ b/ios/MullvadMockData/MullvadREST/MockRelayCache.swift @@ -6,6 +6,7 @@ // Copyright © 2025 Mullvad VPN AB. All rights reserved. // import Foundation + @testable import MullvadREST public struct MockRelayCache: RelayCacheProtocol { diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index eb7a32dcd8..2251f071e2 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -561,6 +561,7 @@ 7A6389ED2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389EC2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift */; }; 7A6389F82B864CDF008E77E1 /* LocationNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389F72B864CDF008E77E1 /* LocationNode.swift */; }; 7A6652B82BB44C3E0042D848 /* LocationDiffableDataSourceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6652B62BB44B120042D848 /* LocationDiffableDataSourceProtocol.swift */; }; + 7A6811542DC8EC6E009CB61A /* UIFont+Weight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */; }; 7A6B4F592AB8412E00123853 /* TunnelMonitorTimings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */; }; 7A6F2FA52AFA3CB2006D0856 /* AccountExpiryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */; }; 7A6F2FA72AFBB9AE006D0856 /* AccountExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FA62AFBB9AE006D0856 /* AccountExpiry.swift */; }; @@ -6015,6 +6016,7 @@ A9A5FA0D2ACB05160083449F /* StorePaymentManagerError.swift in Sources */, A9A5FA0E2ACB05160083449F /* StorePaymentObserver.swift in Sources */, A9A5FA0F2ACB05160083449F /* StoreSubscription.swift in Sources */, + 7A6811542DC8EC6E009CB61A /* UIFont+Weight.swift in Sources */, A9A5FA102ACB05160083449F /* PacketTunnelTransport.swift in Sources */, 7AD63A472CDA666100445268 /* UIntTests.swift in Sources */, A9A5FA112ACB05160083449F /* TransportMonitor.swift in Sources */, diff --git a/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3ac5cc069c..190c222682 100644 --- a/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "df1c07b51917a1cc3ae17733b2b162190d08c52c14f2eb6f68410c133c2f28cc", + "originHash" : "159534cbc9364e3882c14ea288a06d732b74555d93ae12274bcfad4509104caf", "pins" : [ { "identity" : "swift-log", diff --git a/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift b/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift index 5cb79a5705..ef357445c5 100644 --- a/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift +++ b/ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift @@ -7,6 +7,7 @@ // import Foundation +import Operations private let kLogDelimiter = "====================" private let kRedactedPlaceholder = "[REDACTED]" @@ -16,6 +17,7 @@ private let kRedactedContainerPlaceholder = "[REDACTED CONTAINER PATH]" class ConsolidatedApplicationLog: TextOutputStreamable, @unchecked Sendable { typealias Metadata = KeyValuePairs<MetadataKey, String> private let bufferSize: UInt64 + private var workItem: DispatchWorkItem? enum MetadataKey: String { case id, os @@ -50,21 +52,36 @@ class ConsolidatedApplicationLog: TextOutputStreamable, @unchecked Sendable { } } - func addLogFiles(fileURLs: [URL], completion: (@Sendable () -> Void)? = nil) { - logQueue.async(flags: .barrier) { + func cancel() { + workItem?.cancel() + } + + func addLogFiles(fileURLs: [URL], completion: (@Sendable (Result<String, Error>) -> Void)? = nil) { + let workItem = DispatchWorkItem { [weak self] in for fileURL in fileURLs { - self.addSingleLogFile(fileURL) + guard let workItem = self?.workItem, !workItem.isCancelled else { + DispatchQueue.main.async { + completion?(.failure(OperationError.cancelled)) + } + return + } + + self?.addSingleLogFile(fileURL) } - DispatchQueue.main.async { - completion?() + DispatchQueue.main.async { [weak self] in + guard let self else { return } + completion?(.success(self.string)) } } + self.workItem = workItem + + logQueue.async(execute: workItem) } func addError(message: String, error: String, completion: (@Sendable () -> Void)? = nil) { let redactedError = redact(string: error) - logQueue.async(flags: .barrier) { - self.logs.append(LogAttachment(label: message, content: redactedError)) + safeAsync { [weak self] in + self?.logs.append(LogAttachment(label: message, content: redactedError)) DispatchQueue.main.async { completion?() } @@ -108,6 +125,17 @@ class ConsolidatedApplicationLog: TextOutputStreamable, @unchecked Sendable { return result } + private func safeAsync(execute: @escaping @Sendable () -> Void) { + let isCancelled = workItem?.isCancelled ?? false + guard !isCancelled else { return } + + logQueue.async { + if !isCancelled { + execute() + } + } + } + private func addSingleLogFile(_ fileURL: URL) { guard fileURL.isFileURL else { addError( @@ -122,8 +150,8 @@ class ConsolidatedApplicationLog: TextOutputStreamable, @unchecked Sendable { if let lossyString = readFileLossy(path: path, maxBytes: bufferSize) { let redactedString = redact(string: lossyString) - logQueue.async(flags: .barrier) { - self.logs.append(LogAttachment(label: redactedPath, content: redactedString)) + safeAsync { [weak self] in + self?.logs.append(LogAttachment(label: redactedPath, content: redactedString)) } } else { addError(message: redactedPath, error: "Log file does not exist: \(path).") diff --git a/ios/MullvadVPN/Classes/MarkdownStylingOptions.swift b/ios/MullvadVPN/Classes/MarkdownStylingOptions.swift index 07811aec1b..5075cae646 100644 --- a/ios/MullvadVPN/Classes/MarkdownStylingOptions.swift +++ b/ios/MullvadVPN/Classes/MarkdownStylingOptions.swift @@ -11,9 +11,4 @@ import UIKit struct MarkdownStylingOptions { var font: UIFont var paragraphStyle: NSParagraphStyle = .default - - var boldFont: UIFont { - let fontDescriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) ?? font.fontDescriptor - return UIFont(descriptor: fontDescriptor, size: font.pointSize) - } } diff --git a/ios/MullvadVPN/Extensions/NSAttributedString+Extensions.swift b/ios/MullvadVPN/Extensions/NSAttributedString+Extensions.swift index 9e5745a56f..fd9ebc5e7e 100644 --- a/ios/MullvadVPN/Extensions/NSAttributedString+Extensions.swift +++ b/ios/MullvadVPN/Extensions/NSAttributedString+Extensions.swift @@ -27,7 +27,7 @@ extension NSAttributedString { if stringIndex % 2 == 0 { attributes[.font] = options.font } else { - attributes[.font] = options.boldFont + attributes[.font] = options.font.withWeight(.bold) attributes.merge(applyEffect?(.bold, string) ?? [:], uniquingKeysWith: { $1 }) } diff --git a/ios/MullvadVPN/Extensions/UIFont+Weight.swift b/ios/MullvadVPN/Extensions/UIFont+Weight.swift index c3438650eb..146cbd128b 100644 --- a/ios/MullvadVPN/Extensions/UIFont+Weight.swift +++ b/ios/MullvadVPN/Extensions/UIFont+Weight.swift @@ -17,4 +17,13 @@ extension UIFont { return UIFont(descriptor: descriptor, size: 0) } + + func withWeight(_ weight: UIFont.Weight) -> UIFont { + let newDescriptor = fontDescriptor.addingAttributes([ + .traits: [ + UIFontDescriptor.TraitKey.weight: weight, + ], + ]) + return UIFont(descriptor: newDescriptor, size: pointSize) + } } diff --git a/ios/MullvadVPN/Notifications/Notification Providers/LatestChangesNotificationProvider.swift b/ios/MullvadVPN/Notifications/Notification Providers/LatestChangesNotificationProvider.swift index 59441d899d..02991b5848 100644 --- a/ios/MullvadVPN/Notifications/Notification Providers/LatestChangesNotificationProvider.swift +++ b/ios/MullvadVPN/Notifications/Notification Providers/LatestChangesNotificationProvider.swift @@ -60,12 +60,12 @@ class LatestChangesNotificationProvider: NotificationProvider, InAppNotification value: "**Tap here** to see what’s new.", comment: "" ), - options: MarkdownStylingOptions(font: UIFont.preferredFont(forTextStyle: .body)), - applyEffect: { markdownType, _ in - guard case .bold = markdownType else { return [:] } - return [.foregroundColor: UIColor.InAppNotificationBanner.titleColor] - } - ) + options: MarkdownStylingOptions( + font: .preferredFont(forTextStyle: .body) + ) + ) { _, _ in + [.foregroundColor: UIColor.InAppNotificationBanner.titleColor] + } } private func createCloseButtonAction() -> InAppNotificationAction { diff --git a/ios/MullvadVPN/Notifications/Notification Providers/NewDeviceNotificationProvider.swift b/ios/MullvadVPN/Notifications/Notification Providers/NewDeviceNotificationProvider.swift index 22a7a09da5..66a448db4d 100644 --- a/ios/MullvadVPN/Notifications/Notification Providers/NewDeviceNotificationProvider.swift +++ b/ios/MullvadVPN/Notifications/Notification Providers/NewDeviceNotificationProvider.swift @@ -33,14 +33,13 @@ final class NewDeviceNotificationProvider: NotificationProvider, let deviceName = storedDeviceData?.capitalizedName ?? "" let string = String(format: formattedString, deviceName) - let stylingOptions = MarkdownStylingOptions(font: .systemFont(ofSize: 14.0)) - - return NSAttributedString(markdownString: string, options: stylingOptions) { markdownType, _ in - if case .bold = markdownType { - return [.foregroundColor: UIColor.InAppNotificationBanner.titleColor] - } else { - return [:] - } + return NSAttributedString( + markdownString: string, + options: MarkdownStylingOptions( + font: .preferredFont(forTextStyle: .body) + ) + ) { _, _ in + [.foregroundColor: UIColor.InAppNotificationBanner.titleColor] } } diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift index 190e1e7281..521b5bc95f 100644 --- a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift +++ b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift @@ -328,9 +328,7 @@ class AccountDeletionContentView: UIView { ) messageLabel.attributedText = NSAttributedString( markdownString: text, - options: MarkdownStylingOptions( - font: .preferredFont(forTextStyle: .body) - ) + options: MarkdownStylingOptions(font: .preferredFont(forTextStyle: .body)) ) } } diff --git a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift index 8663d8bdb3..b297401213 100644 --- a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift +++ b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift @@ -200,9 +200,7 @@ class DeviceManagementViewController: UIViewController, RootContainment { let attributedText = NSAttributedString( markdownString: text, - options: MarkdownStylingOptions( - font: .preferredFont(forTextStyle: .body) - ) + options: MarkdownStylingOptions(font: .preferredFont(forTextStyle: .body)) ) let presentation = AlertPresentation( diff --git a/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift b/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift index ffdffee232..4e2bb87763 100644 --- a/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift +++ b/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift @@ -147,7 +147,7 @@ class OutOfTimeContentView: UIView { func setBodyLabelText(_ text: String) { bodyLabel.attributedText = NSAttributedString( markdownString: text, - options: MarkdownStylingOptions(font: .systemFont(ofSize: 17)) + options: MarkdownStylingOptions(font: .preferredFont(forTextStyle: .body)) ) } } diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift index 98746416dc..400db47dd1 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift @@ -16,6 +16,7 @@ final class ProblemReportInteractor: @unchecked Sendable { private let tunnelManager: TunnelManager private let consolidatedLog: ConsolidatedApplicationLog private var reportedString = "" + private var requestCancellable: Cancellable? init(apiProxy: APIQuerying, tunnelManager: TunnelManager) { self.apiProxy = apiProxy @@ -28,13 +29,10 @@ final class ProblemReportInteractor: @unchecked Sendable { ) } - func fetchReportString(completion: @escaping @Sendable (String) -> Void) { + func fetchReportString(completion: @escaping @Sendable (Result<String, Error>) -> Void) { consolidatedLog.addLogFiles(fileURLs: ApplicationTarget.allCases.flatMap { ApplicationConfiguration.logFileURLs(for: $0, in: ApplicationConfiguration.containerURL) - }) { [weak self] in - guard let self else { return } - completion(consolidatedLog.string) - } + }, completion: completion) } func sendReport( @@ -43,15 +41,19 @@ final class ProblemReportInteractor: @unchecked Sendable { completion: @escaping @Sendable (Result<Void, Error>) -> Void ) { let logString = self.consolidatedLog.string - if logString.isEmpty { - fetchReportString { [weak self] updatedLogString in - self?.sendProblemReport( - email: email, - message: message, - logString: updatedLogString, - completion: completion - ) + fetchReportString { [weak self] result in + switch result { + case let .success(logString): + self?.sendProblemReport( + email: email, + message: message, + logString: logString, + completion: completion + ) + case let .failure(error): + completion(.failure(error)) + } } } else { sendProblemReport( @@ -63,6 +65,11 @@ final class ProblemReportInteractor: @unchecked Sendable { } } + func cancelSendingReport() { + consolidatedLog.cancel() + requestCancellable?.cancel() + } + private func sendProblemReport( email: String, message: String, @@ -80,10 +87,10 @@ final class ProblemReportInteractor: @unchecked Sendable { metadata: metadataDict ) - _ = self.apiProxy.sendProblemReport(request, retryStrategy: .default, completionHandler: { result in + requestCancellable = self.apiProxy.sendProblemReport(request, retryStrategy: .default) { result in DispatchQueue.main.async { completion(result) } - }) + } } } diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift index 01d7afb09c..3ba1ca5f58 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift @@ -95,20 +95,28 @@ class ProblemReportReviewViewController: UIViewController { private func loadLogs() { spinnerView.startAnimating() - interactor.fetchReportString { [weak self] reportString in + interactor.fetchReportString { [weak self] result in guard let self else { return } - Task { @MainActor in - textView.text = reportString - spinnerView.stopAnimating() - spinnerContainerView.isHidden = true + + if case let .success(reportString) = result { + Task { @MainActor in + textView.text = reportString + spinnerView.stopAnimating() + spinnerContainerView.isHidden = true + } } } } #if DEBUG private func share() { - interactor.fetchReportString { [weak self] reportString in - guard let self,!reportString.isEmpty else { return } + interactor.fetchReportString { [weak self] result in + guard + let self, + case let .success(reportString) = result, + !reportString.isEmpty + else { return } + Task { @MainActor in let activityController = UIActivityViewController( activityItems: [reportString], diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportSubmissionOverlayView.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportSubmissionOverlayView.swift index e4b68a4cea..0d7148380f 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportSubmissionOverlayView.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportSubmissionOverlayView.swift @@ -11,6 +11,8 @@ import MullvadREST import UIKit class ProblemReportSubmissionOverlayView: UIView { + var viewLogsButtonAction: (() -> Void)? + var cancelButtonAction: (() -> Void)? var editButtonAction: (() -> Void)? var retryButtonAction: (() -> Void)? @@ -19,24 +21,28 @@ class ProblemReportSubmissionOverlayView: UIView { case sent(_ email: String) case failure(Error) + var supportEmail: String { + "support@mullvadvpn.net" + } + var title: String? { switch self { case .sending: - return NSLocalizedString( + NSLocalizedString( "SUBMISSION_STATUS_SENDING", tableName: "ProblemReport", value: "Sending...", comment: "" ) case .sent: - return NSLocalizedString( + NSLocalizedString( "SUBMISSION_STATUS_SENT", tableName: "ProblemReport", value: "Sent", comment: "" ) case .failure: - return NSLocalizedString( + NSLocalizedString( "SUBMISSION_STATUS_FAILURE", tableName: "ProblemReport", value: "Failed to send", @@ -45,7 +51,7 @@ class ProblemReportSubmissionOverlayView: UIView { } } - var body: NSAttributedString? { + var body: [NSAttributedString]? { switch self { case .sending: return nil @@ -93,14 +99,44 @@ class ProblemReportSubmissionOverlayView: UIView { combinedAttributedString.append(emailAttributedString) } - return combinedAttributedString + return [combinedAttributedString] - case let .failure(error): - if let error = error as? REST.Error { - return error.displayErrorDescription.flatMap { NSAttributedString(string: $0) } - } else { - return NSAttributedString(string: error.localizedDescription) - } + case .failure: + return [ + NSAttributedString( + string: NSLocalizedString( + "MESSAGE_FAILED_PART_1", + tableName: "ProblemReport", + value: + """ + If you exit the form and try again later, the information you already entered will still \ + be here. + """, + comment: "" + ) + ), + NSAttributedString( + markdownString: NSLocalizedString( + "MESSAGE_FAILED_PART_2", + tableName: "ProblemReport", + value: + """ + If you still experience issues you can email our support directly at \ + **\(supportEmail)**. Please attach your app log to your email. + """, + comment: "" + ), + options: MarkdownStylingOptions( + font: .preferredFont(forTextStyle: .body) + ), applyEffect: { _, _ in + [ + // Setting font again to circumvent bold weight. + .font: UIFont.preferredFont(forTextStyle: .body), + .foregroundColor: UIColor.white, + ] + } + ), + ] } } } @@ -127,24 +163,54 @@ class ProblemReportSubmissionOverlayView: UIView { return textLabel }() - let bodyLabel: UILabel = { - let textLabel = UILabel() - textLabel.font = UIFont.systemFont(ofSize: 17) - textLabel.textColor = .white - textLabel.numberOfLines = 0 - return textLabel + let bodyLabelContainer: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 24 + return stackView }() - /// Footer stack view that contains action buttons - private lazy var buttonsStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [self.editMessageButton, self.tryAgainButton]) + /// Footer stack view that contains action buttons. + private lazy var buttonContainer: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [cancelButton, failedToSendButtons]) stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical stackView.spacing = 18 + return stackView + }() + /// Footer stack view that contains action buttons when sending failed. + private lazy var failedToSendButtons: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [editMessageButton, viewLogsButton, tryAgainButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 18 return stackView }() + private lazy var viewLogsButton: AppButton = { + let button = AppButton(style: .default) + button.setAccessibilityIdentifier(.problemReportAppLogsButton) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(ProblemReportViewModel.viewLogsButtonTitle, for: .normal) + button.addTarget(self, action: #selector(handleViewLogsButton), for: .touchUpInside) + return button + }() + + private lazy var cancelButton: AppButton = { + let button = AppButton(style: .default) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(NSLocalizedString( + "CANCEL_BUTTON", + tableName: "ProblemReport", + value: "Cancel", + comment: "" + ), for: .normal) + button.addTarget(self, action: #selector(handleCancelButton), for: .touchUpInside) + return button + }() + private lazy var editMessageButton: AppButton = { let button = AppButton(style: .default) button.translatesAutoresizingMaskIntoConstraints = false @@ -189,10 +255,10 @@ class ProblemReportSubmissionOverlayView: UIView { private func addSubviews() { for subview in [ titleLabel, - bodyLabel, + bodyLabelContainer, activityIndicator, statusImageView, - buttonsStackView, + buttonContainer, ] { subview.translatesAutoresizingMaskIntoConstraints = false addSubview(subview) @@ -212,49 +278,81 @@ class ProblemReportSubmissionOverlayView: UIView { titleLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), titleLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), - bodyLabel.topAnchor.constraint( + bodyLabelContainer.topAnchor.constraint( equalToSystemSpacingBelow: titleLabel.bottomAnchor, multiplier: 1 ), - bodyLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), - bodyLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), - buttonsStackView.topAnchor.constraint( - greaterThanOrEqualTo: bodyLabel.bottomAnchor, + bodyLabelContainer.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + bodyLabelContainer.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + buttonContainer.topAnchor.constraint( + greaterThanOrEqualTo: bodyLabelContainer.bottomAnchor, constant: 18 ), - buttonsStackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), - buttonsStackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), - buttonsStackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + buttonContainer.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + buttonContainer.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + buttonContainer.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), ]) } private func transitionToState(_ state: State) { titleLabel.text = state.title - bodyLabel.attributedText = state.body + + bodyLabelContainer.subviews.forEach { $0.removeFromSuperview() } + state.body?.forEach { attributedString in + let textLabel = UILabel() + textLabel.font = UIFont.systemFont(ofSize: 17) + textLabel.textColor = .white.withAlphaComponent(0.6) + textLabel.numberOfLines = 0 + textLabel.attributedText = attributedString + + if attributedString.string.contains(state.supportEmail) { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleEmailLabelTap)) + textLabel.addGestureRecognizer(tapGesture) + textLabel.isUserInteractionEnabled = true + } + + bodyLabelContainer.addArrangedSubview(textLabel) + } switch state { case .sending: activityIndicator.startAnimating() statusImageView.isHidden = true - buttonsStackView.isHidden = true + cancelButton.isHidden = false + failedToSendButtons.isHidden = true case .sent: activityIndicator.stopAnimating() statusImageView.style = .success statusImageView.isHidden = false - buttonsStackView.isHidden = true + buttonContainer.isHidden = true case .failure: activityIndicator.stopAnimating() statusImageView.style = .failure statusImageView.isHidden = false - buttonsStackView.isHidden = false + cancelButton.isHidden = true + failedToSendButtons.isHidden = false } } // MARK: - Actions + @objc private func handleEmailLabelTap() { + if let url = URL(string: "mailto:\(state.supportEmail)") { + UIApplication.shared.open(url) + } + } + + @objc private func handleViewLogsButton() { + viewLogsButtonAction?() + } + + @objc private func handleCancelButton() { + cancelButtonAction?() + } + @objc private func handleEditButton() { editButtonAction?() } diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController+ViewManagement.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController+ViewManagement.swift index 484c60ef8f..c5370188eb 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController+ViewManagement.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController+ViewManagement.swift @@ -30,7 +30,7 @@ extension ProblemReportViewController { textLabel.translatesAutoresizingMaskIntoConstraints = false textLabel.numberOfLines = 0 textLabel.textColor = .white - textLabel.text = Self.persistentViewModel.subheadLabelText + textLabel.text = ProblemReportViewModel.subheadLabelText return textLabel } @@ -48,7 +48,7 @@ extension ProblemReportViewController { textField.backgroundColor = .white textField.inputAccessoryView = emailAccessoryToolbar textField.font = UIFont.systemFont(ofSize: 17) - textField.placeholder = Self.persistentViewModel.emailPlaceholderText + textField.placeholder = ProblemReportViewModel.emailPlaceholderText return textField } @@ -58,7 +58,7 @@ extension ProblemReportViewController { textView.backgroundColor = .white textView.inputAccessoryView = messageAccessoryToolbar textView.font = UIFont.systemFont(ofSize: 17) - textView.placeholder = Self.persistentViewModel.messageTextViewPlaceholder + textView.placeholder = ProblemReportViewModel.messageTextViewPlaceholder textView.contentInsetAdjustmentBehavior = .never return textView @@ -90,7 +90,7 @@ extension ProblemReportViewController { let button = AppButton(style: .default) button.setAccessibilityIdentifier(.problemReportAppLogsButton) button.translatesAutoresizingMaskIntoConstraints = false - button.setTitle(Self.persistentViewModel.viewLogsButtonTitle, for: .normal) + button.setTitle(ProblemReportViewModel.viewLogsButtonTitle, for: .normal) button.addTarget(self, action: #selector(handleViewLogsButtonTap), for: .touchUpInside) return button } @@ -99,7 +99,7 @@ extension ProblemReportViewController { let button = AppButton(style: .success) button.setAccessibilityIdentifier(.problemReportSendButton) button.translatesAutoresizingMaskIntoConstraints = false - button.setTitle(Self.persistentViewModel.sendLogsButtonTitle, for: .normal) + button.setTitle(ProblemReportViewModel.sendLogsButtonTitle, for: .normal) button.addTarget(self, action: #selector(handleSendButtonTap), for: .touchUpInside) return button } @@ -108,6 +108,14 @@ extension ProblemReportViewController { let overlay = ProblemReportSubmissionOverlayView() overlay.translatesAutoresizingMaskIntoConstraints = false + overlay.viewLogsButtonAction = { [weak self] in + self?.handleViewLogsButtonTap() + } + + overlay.cancelButtonAction = { [weak self] in + self?.interactor.cancelSendingReport() + } + overlay.editButtonAction = { [weak self] in self?.hideSubmissionOverlay() } diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift index 0e7cda5f44..e1b37598cb 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift @@ -12,8 +12,8 @@ import Operations import UIKit final class ProblemReportViewController: UIViewController, UITextFieldDelegate { - private let interactor: ProblemReportInteractor private let alertPresenter: AlertPresenter + let interactor: ProblemReportInteractor var textViewKeyboardResponder: AutomaticKeyboardResponder? var scrollViewKeyboardResponder: AutomaticKeyboardResponder? @@ -77,7 +77,7 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { view.backgroundColor = .secondaryColor view.setAccessibilityIdentifier(.problemReportView) - navigationItem.title = Self.persistentViewModel.navigationTitle + navigationItem.title = ProblemReportViewModel.navigationTitle textViewKeyboardResponder = AutomaticKeyboardResponder(targetView: messageTextView) scrollViewKeyboardResponder = AutomaticKeyboardResponder(targetView: scrollView) @@ -170,17 +170,17 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { let presentation = AlertPresentation( id: "problem-report-alert", icon: .alert, - message: Self.persistentViewModel.emptyEmailAlertWarning, + message: ProblemReportViewModel.emptyEmailAlertWarning, buttons: [ AlertAction( - title: Self.persistentViewModel.confirmEmptyEmailTitle, + title: ProblemReportViewModel.confirmEmptyEmailTitle, style: .destructive, handler: { completion(true) } ), AlertAction( - title: Self.persistentViewModel.cancelEmptyEmailTitle, + title: ProblemReportViewModel.cancelEmptyEmailTitle, style: .default, handler: { completion(false) @@ -245,7 +245,11 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { clearPersistentViewModel() case let .failure(error): - submissionOverlayView.state = .failure(error) + if let error = error as? OperationError, error == .cancelled { + hideSubmissionOverlay() + } else { + submissionOverlayView.state = .failure(error) + } } navigationItem.setHidesBackButton(false, animated: true) @@ -261,9 +265,9 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { interactor.sendReport( email: viewModel.email, message: viewModel.message - ) { completion in + ) { [weak self] completion in Task { @MainActor in - self.didSendProblemReport(viewModel: viewModel, completion: completion) + self?.didSendProblemReport(viewModel: viewModel, completion: completion) } } } diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewModel.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewModel.swift index 6805b97b6c..e6537ec46b 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewModel.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewModel.swift @@ -12,14 +12,14 @@ struct ProblemReportViewModel { let email: String let message: String - let navigationTitle = NSLocalizedString( + static let navigationTitle = NSLocalizedString( "NAVIGATION_TITLE", tableName: "ProblemReport", value: "Report a problem", comment: "" ) - let subheadLabelText = NSLocalizedString( + static let subheadLabelText = NSLocalizedString( "SUBHEAD_LABEL", tableName: "ProblemReport", value: """ @@ -30,14 +30,14 @@ struct ProblemReportViewModel { comment: "" ) - let emailPlaceholderText = NSLocalizedString( + static let emailPlaceholderText = NSLocalizedString( "EMAIL_TEXTFIELD_PLACEHOLDER", tableName: "ProblemReport", value: "Your email (optional)", comment: "" ) - let messageTextViewPlaceholder = NSLocalizedString( + static let messageTextViewPlaceholder = NSLocalizedString( "DESCRIPTION_TEXTVIEW_PLACEHOLDER", tableName: "ProblemReport", value: """ @@ -47,21 +47,21 @@ struct ProblemReportViewModel { comment: "" ) - let viewLogsButtonTitle = NSLocalizedString( + static let viewLogsButtonTitle = NSLocalizedString( "VIEW_APP_LOGS_BUTTON_TITLE", tableName: "ProblemReport", value: "View app logs", comment: "" ) - let sendLogsButtonTitle = NSLocalizedString( + static let sendLogsButtonTitle = NSLocalizedString( "SEND_BUTTON_TITLE", tableName: "ProblemReport", value: "Send", comment: "" ) - let emptyEmailAlertWarning = NSLocalizedString( + static let emptyEmailAlertWarning = NSLocalizedString( "EMPTY_EMAIL_ALERT_MESSAGE", tableName: "ProblemReport", value: """ @@ -71,14 +71,14 @@ struct ProblemReportViewModel { comment: "" ) - let confirmEmptyEmailTitle = NSLocalizedString( + static let confirmEmptyEmailTitle = NSLocalizedString( "EMPTY_EMAIL_ALERT_SEND_ANYWAY_ACTION", tableName: "ProblemReport", value: "Send anyway", comment: "" ) - let cancelEmptyEmailTitle = NSLocalizedString( + static let cancelEmptyEmailTitle = NSLocalizedString( "EMPTY_EMAIL_ALERT_CANCEL_ACTION", tableName: "ProblemReport", value: "Cancel", |
