diff --git a/Modules/Sources/Support/Extensions/Foundation.swift b/Modules/Sources/Support/Extensions/Foundation.swift index 59daf9c6b2b5..bf37d61f189c 100644 --- a/Modules/Sources/Support/Extensions/Foundation.swift +++ b/Modules/Sources/Support/Extensions/Foundation.swift @@ -29,29 +29,6 @@ extension String { } } -extension AttributedString { - func toHtml() -> String { - NSAttributedString(self).toHtml() - } -} - -extension NSAttributedString { - func toHtml() -> String { - let documentAttributes = [ - NSAttributedString.DocumentAttributeKey.documentType: NSAttributedString.DocumentType.html - ] - - guard - let htmlData = try? self.data(from: NSMakeRange(0, self.length), documentAttributes: documentAttributes), - let htmlString = String(data: htmlData, encoding: .utf8) - else { - return self.string - } - - return htmlString - } -} - func convertMarkdownHeadingsToBold(in markdown: String) -> String { let lines = markdown.components(separatedBy: .newlines) var convertedLines: [String] = [] diff --git a/Modules/Sources/Support/InternalDataProvider.swift b/Modules/Sources/Support/InternalDataProvider.swift index 98e9092c78f6..6e2a73beba34 100644 --- a/Modules/Sources/Support/InternalDataProvider.swift +++ b/Modules/Sources/Support/InternalDataProvider.swift @@ -10,7 +10,6 @@ extension SupportDataProvider { applicationLogProvider: InternalLogDataProvider(), botConversationDataProvider: InternalBotConversationDataProvider(), userDataProvider: InternalUserDataProvider(), - supportConversationDataProvider: InternalSupportConversationDataProvider(), diagnosticsDataProvider: InternalDiagnosticsDataProvider(), mediaHost: InternalMediaHost() ) @@ -107,156 +106,6 @@ extension SupportDataProvider { ) ]) } - - static let supportConversationSummaries: [ConversationSummary] = [ - ConversationSummary( - id: 1, - title: "Login Issues with Two-Factor Authentication", - description: "I'm having trouble logging into my account. The two-factor authentication code isn't working properly and I keep getting locked out.", - status: .waitingForSupport, - lastMessageSentAt: Date().addingTimeInterval(-300) // 5 minutes ago - ), - ConversationSummary( - id: 2, - title: "Billing Question - Duplicate Charges", - description: "I noticed duplicate charges on my credit card statement for this month's subscription. Can you help me understand what happened?", - status: .waitingForUser, - lastMessageSentAt: Date().addingTimeInterval(-3600) // 1 hour ago - ), - ConversationSummary( - id: 3, - title: "Feature Request: Dark Mode Support", - description: "Would it be possible to add dark mode support to the mobile app? Many users in our team have been requesting this feature.", - status: .resolved, - lastMessageSentAt: Date().addingTimeInterval(-86400) // 1 day ago - ), - ConversationSummary( - id: 4, - title: "Data Export Not Working", - description: "I'm trying to export my data but the process keeps failing at 50%. Is there a known issue with large datasets?", - status: .resolved, - lastMessageSentAt: Date().addingTimeInterval(-172800) // 2 days ago - ), - ConversationSummary( - id: 5, - title: "Account Migration Assistance", - description: "I need help migrating my old account to the new system. I have several years of data that I don't want to lose.", - status: .resolved, - lastMessageSentAt: Date().addingTimeInterval(-259200) // 3 days ago - ), - ConversationSummary( - id: 6, - title: "API Rate Limiting Questions", - description: "Our application is hitting rate limits frequently. Can we discuss increasing our API quota or optimizing our usage patterns?", - status: .closed, - lastMessageSentAt: Date().addingTimeInterval(-604800) // 1 week ago - ), - ConversationSummary( - id: 7, - title: "Security Concern - Suspicious Activity", - description: "I received an email about suspicious activity on my account. I want to make sure my account is secure and review recent access logs.", - status: .closed, - lastMessageSentAt: Date().addingTimeInterval(-1209600) // 2 weeks ago - ), - ConversationSummary( - id: 8, - title: "Integration Help with Webhook Setup", - description: "I'm having trouble setting up webhooks for our CRM integration. The endpoints aren't receiving the expected payload format.", - status: .closed, - lastMessageSentAt: Date().addingTimeInterval(-1814400) // 3 weeks ago - ) - ] - - static let supportConversation = Conversation( - id: 1, - title: "Issue with app crashes", - description: "The app keeps crashing when I try to upload photos. This has been happening for the past week and is very frustrating.", - lastMessageSentAt: Date().addingTimeInterval(-2400), - status: .closed, - messages: [ - Message( - id: 1, - content: "Hello! I'm having trouble with the app crashing when I try to upload photos. Can you help?", - createdAt: Date().addingTimeInterval(-3600), - authorName: "Test User", - authorIsUser: true, - attachments: [] - ), - Message( - id: 2, - content: "Hi there! I'm sorry to hear you're experiencing crashes. Let me help you troubleshoot this issue. Can you tell me what device you're using and what version of the app?", - createdAt: Date().addingTimeInterval(-3000), - authorName: "Support Engineer Alice", - authorIsUser: false, - attachments: [] - ), - Message( - id: 3, - content: "I'm using an iPhone 14 Pro with iOS 17.1 and the latest version of the app from the App Store. The crashes seem to happen right after I tap the Upload button and pick a photo from my library.", - createdAt: Date().addingTimeInterval(-2400), - authorName: "Test User", - authorIsUser: true, - attachments: [] - ) - , - Message( - id: 4, - content: "Understood. Do you notice this with any photo, or only certain ones (for example, very large HEIF images or Live Photos)?", - createdAt: Date().addingTimeInterval(-1950), - authorName: "Support Engineer Alice", - authorIsUser: false, - attachments: [] - ), - Message( - id: 5, - content: "It happens mostly with Live Photos. Regular photos sometimes work.", - createdAt: Date().addingTimeInterval(-1800), - authorName: "Test User", - authorIsUser: true, - attachments: [ - Attachment( - id: 1234, - filename: "sample-1234.jpg", - contentType: "application/jpeg", - fileSize: 1234, - url: URL(string: "https://picsum.photos/seed/1/800/600")! - ) - ] - ), - Message( - id: 6, - content: "Thanks, that helps. We recently fixed an issue with Live Photo processing. Could you try disabling Live Photo upload in Settings > Upload Options and try again?", - createdAt: Date().addingTimeInterval(-1650), - authorName: "Support Engineer Alice", - authorIsUser: false, - attachments: [] - ), - Message( - id: 7, - content: "I disabled Live Photo upload and the app no longer crashes. Upload works now!", - createdAt: Date().addingTimeInterval(-1500), - authorName: "Test User", - authorIsUser: true, - attachments: [] - ), - Message( - id: 8, - content: "Great to hear! We'll include the fix in the next update so Live Photos work without disabling. In the meantime, you can keep that setting off. Anything else I can help with?", - createdAt: Date().addingTimeInterval(-1350), - authorName: "Support Engineer Alice", - authorIsUser: false, - attachments: [] - ), - Message( - id: 9, - content: "No, that's all. Thanks for the quick help!", - createdAt: Date().addingTimeInterval(-1200), - authorName: "Test User", - authorIsUser: true, - attachments: [] - ) - ] - ) } actor InternalLogDataProvider: ApplicationLogDataProvider { @@ -347,79 +196,6 @@ actor InternalUserDataProvider: CurrentUserDataProvider { } } -actor InternalSupportConversationDataProvider: SupportConversationDataProvider { - let maximumUploadSize: UInt64 = 5_000_000 // 5MB - - private var conversations: [UInt64: Conversation] = [:] - - nonisolated func loadSupportConversations() throws -> any CachedAndFetchedResult<[ConversationSummary]> { - UncachedResult { - return await SupportDataProvider.supportConversationSummaries - } - } - - nonisolated func loadSupportConversation(id: UInt64) throws -> any CachedAndFetchedResult { - UncachedResult { - let conversation = await SupportDataProvider.supportConversation - await self.cache(conversation) - return conversation - } - } - - func replyToSupportConversation( - id: UInt64, - message: String, - user: SupportUser, - attachments: [URL] - ) async throws -> Conversation { - - let conversation = try await loadSupportConversation(id: id).fetchedResult() - - if Bool.random() { - throw CocoaError(.validationInvalidDate) - } - - let newMessage = Message( - id: UInt64.random(in: 0...UInt64.max), - content: message, - createdAt: Date(), - authorName: user.username, - authorIsUser: true, - attachments: [] // TODO - ) - - try await Task.sleep(for: .seconds(3)) - return conversation.addingMessage(newMessage) - } - - func createSupportConversation( - subject: String, - message: String, - user: SupportUser, - attachments: [URL] - ) async throws -> Conversation { - return Conversation( - id: 9999, - title: subject, - description: message, - lastMessageSentAt: Date(), - status: .waitingForSupport, - messages: [Message( - id: 1234, - content: message, - createdAt: Date(), - authorName: user.username, - authorIsUser: true, - attachments: [] - )] - ) - } - - private func cache(_ value: Conversation) { - self.conversations[value.id] = value - } -} - actor InternalDiagnosticsDataProvider: DiagnosticsDataProvider { private var didClear: Bool = false diff --git a/Modules/Sources/Support/Localization.swift b/Modules/Sources/Support/Localization.swift index 7be05f80b714..eb41d371b389 100644 --- a/Modules/Sources/Support/Localization.swift +++ b/Modules/Sources/Support/Localization.swift @@ -3,111 +3,10 @@ import Foundation enum Localization { // MARK: - Shared Constants (used by multiple files) - static let optional = NSLocalizedString( - "com.jetpack.support.optional", - value: "(Optional)", - comment: "Text indicating a field is optional" - ) - static let message = NSLocalizedString( - "com.jetpack.support.message", - value: "Message", - comment: "Section header for message text input" - ) - static let reply = NSLocalizedString( - "com.jetpack.support.reply", - value: "Reply", - comment: "Navigation title for replying to a support conversation" - ) - - // MARK: - SupportForm.swift - - static let title = NSLocalizedString( - "com.jetpack.support.title", - value: "Contact Support", - comment: "Title of the view for contacting support." - ) - static let iNeedHelp = NSLocalizedString( - "com.jetpack.support.iNeedHelp", - value: "I need help with", - comment: "Text on the support form to refer to what area the user has problem with." - ) - static let contactInformation = NSLocalizedString( - "com.jetpack.support.contactInformation", - value: "Contact Information", - comment: "Section title for contact information" - ) - static let issueDetails = NSLocalizedString( - "com.jetpack.support.issueDetails", - value: "Issue Details", - comment: "Section title for issue details" - ) - static let subject = NSLocalizedString( - "com.jetpack.support.subject", - value: "Subject", - comment: "Subject title on the support form" - ) - static let subjectPlaceholder = NSLocalizedString( - "com.jetpack.support.subjectPlaceholder", - value: "Brief summary of your issue", - comment: "Placeholder for subject field" - ) - static let siteAddress = NSLocalizedString( - "com.jetpack.support.siteAddress", - value: "Site Address", - comment: "Site Address title on the support form" - ) - static let siteAddressPlaceholder = NSLocalizedString( - "com.jetpack.support.siteAddressPlaceholder", - value: "https://yoursite.com", - comment: "Placeholder for site address field" - ) - static let submitRequest = NSLocalizedString( - "com.jetpack.support.submitRequest", - value: "Submit Support Request", - comment: "Button title to submit a support request." - ) - static let errorTitle = NSLocalizedString( - "com.jetpack.support.errorTitle", - value: "Error", - comment: "Title for error alerts" - ) - static let gotIt = NSLocalizedString( - "com.jetpack.support.gotIt", - value: "Got It", - comment: "Button to dismiss alerts." - ) - static let supportRequestSent = NSLocalizedString( - "com.jetpack.support.supportRequestSent", - value: "Request Sent!", - comment: "Title for the alert after the support request is created." - ) - static let supportRequestSentMessage = NSLocalizedString( - "com.jetpack.support.supportRequestSentMessage", - value: "Your support request has been sent successfully. We will reply via email as quickly as we can.", - comment: "Message for the alert after the support request is created." - ) - - // MARK: - ScreenshotPicker.swift - - static let screenshots = NSLocalizedString( - "com.jetpack.support.screenshots", - value: "Screenshots", - comment: "Label for screenshots section" - ) - static let screenshotsDescription = NSLocalizedString( - "com.jetpack.support.screenshotsDescription", - value: "Adding screenshots can help us understand and resolve your issue faster.", - comment: "Description for screenshots section" - ) - static let addScreenshots = NSLocalizedString( - "com.jetpack.support.addScreenshots", - value: "Add Screenshots", - comment: "Button to add screenshots" - ) - static let addMoreScreenshots = NSLocalizedString( - "com.jetpack.support.addMoreScreenshots", - value: "Add More Screenshots", - comment: "Button to add more screenshots" + static let cancel = NSLocalizedString( + "com.jetpack.support.cancel", + value: "Cancel", + comment: "Button to cancel current action" ) // MARK: - ApplicationLogPicker.swift @@ -138,115 +37,6 @@ enum Localization { comment: "Toggle label to include application logs in the support request" ) - // MARK: - SupportConversationListView.swift - - static let supportConversations = NSLocalizedString( - "com.jetpack.support.supportConversations", - value: "Support Conversations", - comment: "Navigation title for the support conversations list" - ) - static let loadingConversations = NSLocalizedString( - "com.jetpack.support.loadingConversations", - value: "Loading Conversations", - comment: "Progress text while loading support conversations" - ) - static let errorLoadingSupportConversations = NSLocalizedString( - "com.jetpack.support.errorLoadingSupportConversations", - value: "Error loading support conversations", - comment: "Error message when support conversations fail to load" - ) - - // MARK: - SupportConversationView.swift - - static let loadingMessages = NSLocalizedString( - "com.jetpack.support.loadingMessages", - value: "Loading Messages", - comment: "Progress text while loading conversation messages" - ) - static let unableToDisplayConversation = NSLocalizedString( - "com.jetpack.support.unableToDisplayConversation", - value: "Unable to display conversation", - comment: "Error message when conversation cannot be displayed" - ) - static let messagesCount = NSLocalizedString( - "com.jetpack.support.messagesCount", - value: "%d Messages", - comment: "Format string for number of messages in conversation" - ) - static let lastUpdated = NSLocalizedString( - "com.jetpack.support.lastUpdated", - value: "Last updated %@", - comment: "Format string for when conversation was last updated" - ) - static let attachment = NSLocalizedString( - "com.jetpack.support.attachment", - value: "Attachment %@", - comment: "Format string for attachment identifier" - ) - static let view = NSLocalizedString( - "com.jetpack.support.view", - value: "View", - comment: "Button to view an attachment" - ) - - // MARK: - SupportConversationReplyView.swift - - static let cancel = NSLocalizedString( - "com.jetpack.support.cancel", - value: "Cancel", - comment: "Button to cancel current action" - ) - static let send = NSLocalizedString( - "com.jetpack.support.send", - value: "Send", - comment: "Button to send a message or reply" - ) - static let sending = NSLocalizedString( - "com.jetpack.support.sending", - value: "Sending", - comment: "Progress text while sending a message" - ) - static let sendingMessage = NSLocalizedString( - "com.jetpack.support.sendingMessage", - value: "Sending Message", - comment: "Progress message shown while sending a message" - ) - static let unableToSendMessage = NSLocalizedString( - "com.jetpack.support.unableToSendMessage", - value: "Unable to send Message", - comment: "Error title when message sending fails" - ) - static let messageSent = NSLocalizedString( - "com.jetpack.support.messageSent", - value: "Message Sent", - comment: "Success message when reply is sent successfully" - ) - static let discardChanges = NSLocalizedString( - "com.jetpack.support.discardChanges", - value: "Discard Changes", - comment: "Button to discard changes in a draft message" - ) - static let continueWriting = NSLocalizedString( - "com.jetpack.support.continueWriting", - value: "Continue Writing", - comment: "Button to continue editing a message" - ) - static let confirmCancelMessage = NSLocalizedString( - "com.jetpack.support.confirmCancelMessage", - value: "Are you sure you want to cancel this message? You'll lose any data you've entered", - comment: "Confirmation message when canceling a draft" - ) - static let confirmCancellation = NSLocalizedString( - "com.jetpack.support.confirmCancellation", - value: "Confirm Cancellation", - comment: "Title for alert confirming cancellation" - ) - static let emailNotice = NSLocalizedString( - "com.jetpack.support.emailNotice", - value: "We'll email you at this address.", - comment: "Notice explaining where support will send email responses" - ) - // MARK: - DiagnosticsView.swift static let diagnosticsTitle = NSLocalizedString( @@ -310,14 +100,6 @@ enum Localization { value: "Application Logs", comment: "Navigation title for application logs screen" ) - - // MARK: - ActivityLogDetailView.swift - - static let loadingLogContent = NSLocalizedString( - "com.jetpack.support.loadingLogContent", - value: "Loading log content...", - comment: "Progress message while loading application log content" - ) static let confirmDeleteAllLogs = NSLocalizedString( "com.jetpack.support.confirmDeleteAllLogs", value: "Are you sure you want to delete all logs?", @@ -399,6 +181,14 @@ enum Localization { comment: "Button title to enable extensive logging" ) + // MARK: - ActivityLogDetailView.swift + + static let loadingLogContent = NSLocalizedString( + "com.jetpack.support.loadingLogContent", + value: "Loading log content...", + comment: "Progress message while loading application log content" + ) + // MARK: - ActivityLogSharingView.swift static let share = NSLocalizedString( @@ -467,11 +257,6 @@ enum Localization { // MARK: - ConversationView.swift - static let openSupportTicket = NSLocalizedString( - "com.jetpack.support.openSupportTicket", - value: "Open a Support Ticket", - comment: "Button to open a support ticket" - ) static let loadingBotConversationMessages = NSLocalizedString( "com.jetpack.support.loadingBotConversationMessages", value: "Loading Messages", @@ -504,22 +289,6 @@ enum Localization { comment: "Progress message shown while bot is thinking" ) - // MARK: - SupportConversationView.swift - - static let conversationEnded = NSLocalizedString( - "com.jetpack.support.conversationEnded", - value: "End of conversation. No further replies are possible.", - comment: "Message shown at end of closed support conversation" - ) - - // MARK: - ScreenshotPicker.swift - - static let attachmentLimit = NSLocalizedString( - "com.jetpack.support.attachmentLimit", - value: "Attachment Limit: %1$@ / %2$@", - comment: "Format string for attachment size limit. %1$@ is current size, %2$@ is maximum size" - ) - // MARK: - ErrorView.swift static let tryAgain = NSLocalizedString( @@ -528,13 +297,8 @@ enum Localization { comment: "Button to retry a failed operation" ) - // MARK: - AttachmentListView.swift + // MARK: - SingleVideoView.swift - static let loadingImage = NSLocalizedString( - "com.jetpack.support.loadingImage", - value: "Loading Image", - comment: "Progress message while loading an image attachment" - ) static let loadingVideo = NSLocalizedString( "com.jetpack.support.loadingVideo", value: "Loading Video", diff --git a/Modules/Sources/Support/Model/SupportConversation.swift b/Modules/Sources/Support/Model/SupportConversation.swift deleted file mode 100644 index f9a09b0fc655..000000000000 --- a/Modules/Sources/Support/Model/SupportConversation.swift +++ /dev/null @@ -1,197 +0,0 @@ -import Foundation -import SwiftUI - -public enum ConversationStatus: Sendable, Codable { - case waitingForSupport - case waitingForUser - case resolved - case closed - case unknown // Handles future server updates - - var title: String { - switch self { - case .waitingForSupport: "Waiting for support" - case .waitingForUser: "Waiting for you" - case .resolved: "Solved" - case .closed: "Closed" - case .unknown: "Unknown" - } - } - - var color: Color { - switch self { - case .waitingForSupport: Color.blue - case .waitingForUser: Color.orange - case .resolved: Color.green - case .closed: Color.gray - case .unknown: Color.orange - } - } -} - -public struct ConversationSummary: Identifiable, Hashable, Sendable, Codable, Equatable { - - public let id: UInt64 - public let title: String - public let description: String - public let attributedDescription: AttributedString - public let status: ConversationStatus - - /// The `description` with any markdown formatting stripped out - public let plainTextDescription: String - public let lastMessageSentAt: Date - - public init( - id: UInt64, - title: String, - description: String, - status: ConversationStatus, - lastMessageSentAt: Date - ) { - self.id = id - self.title = title - self.description = description - self.attributedDescription = convertMarkdownTextToAttributedString(description) - self.plainTextDescription = NSAttributedString(attributedDescription).string - self.status = status - self.lastMessageSentAt = lastMessageSentAt - } -} - -public struct Conversation: Identifiable, Sendable, Codable, Equatable { - public let id: UInt64 - public let title: String - public let description: String - public let lastMessageSentAt: Date - public let status: ConversationStatus - public let messages: [Message] - - public init( - id: UInt64, - title: String, - description: String, - lastMessageSentAt: Date, - status: ConversationStatus, - messages: [Message] - ) { - self.id = id - self.title = title - self.description = description - self.lastMessageSentAt = lastMessageSentAt - self.status = status - self.messages = messages - } - - func addingMessage(_ message: Message) -> Conversation { - return Conversation( - id: self.id, - title: self.title, - description: self.description, - lastMessageSentAt: message.createdAt, - status: self.status, - messages: self.messages + [message] - ) - } - - /// Will the server accept a reply to this conversation? - /// - /// Unrelated to whether the user is eligible for support. - var canAcceptReply: Bool { - status != .closed - } -} - -public struct Message: Identifiable, Sendable, Codable, Equatable { - public let id: UInt64 - public let content: String - - /// The `content` with any markdown formatting applied to make Rich Text - public let attributedContent: AttributedString - public let createdAt: Date - public let authorName: String - public let authorIsUser: Bool - public let attachments: [Attachment] - - public init( - id: UInt64, - content: String, - createdAt: Date, - authorName: String, - authorIsUser: Bool, - attachments: [Attachment] - ) { - self.id = id - self.content = content - self.attributedContent = convertMarkdownTextToAttributedString(content) - self.createdAt = createdAt - self.authorName = authorName - self.authorIsUser = authorIsUser - self.attachments = attachments - } - - /// The `content` with any markdown formatting stripped out - var plainTextContent: String { - NSAttributedString(attributedContent).string - } -} - -public struct Attachment: Identifiable, Sendable, Codable, Equatable { - - public struct Dimensions: Sendable, Codable, Equatable { - let width: UInt64 - let height: UInt64 - - public init(width: UInt64, height: UInt64) { - self.width = width - self.height = height - } - } - - public let id: UInt64 - public let filename: String - public let contentType: String - public let fileSize: UInt64 - public let url: URL - - public let dimensions: Dimensions? - - public init( - id: UInt64, - filename: String, - contentType: String, - fileSize: UInt64, - url: URL, - dimensions: Dimensions? = nil - ) { - self.id = id - self.filename = filename - self.contentType = contentType - self.fileSize = fileSize - self.url = url - self.dimensions = dimensions - } - - var isImage: Bool { - contentType.hasPrefix("image/") - } - - var isVideo: Bool { - contentType.hasPrefix("video/") - } - - var isPdf: Bool { - contentType == "application/pdf" - } - - var icon: String { - if isVideo { - return "film" - } - - if isPdf { - return "text.document" - } - - return "doc" - } -} diff --git a/Modules/Sources/Support/Model/SupportFormArea.swift b/Modules/Sources/Support/Model/SupportFormArea.swift deleted file mode 100644 index 9eb2f088c052..000000000000 --- a/Modules/Sources/Support/Model/SupportFormArea.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation - -/// Represents a support area/category that users can select when submitting a support request -public struct SupportFormArea: Identifiable, Hashable, Sendable { - public let id: String - public let title: String - public let description: String? - - public init(id: String, title: String, description: String? = nil) { - self.id = id - self.title = title - self.description = description - } -} - -// MARK: - String Literal Support -extension SupportFormArea: ExpressibleByStringLiteral { - public init(stringLiteral value: String) { - self.id = value.lowercased().replacingOccurrences(of: " ", with: "_") - self.title = value - self.description = nil - } -} - -// MARK: - Common Areas -public extension SupportFormArea { - static let application = SupportFormArea(id: "application", title: "Application", description: "Issues with the app functionality") - static let jetpackConnection = SupportFormArea(id: "jetpack_connection", title: "Jetpack Connection", description: "Problems connecting to Jetpack") - static let siteManagement = SupportFormArea(id: "site_management", title: "Site Management", description: "Issues managing your site") - static let billing = SupportFormArea(id: "billing", title: "Billing & Subscriptions", description: "Payment and subscription issues") - static let technical = SupportFormArea(id: "technical", title: "Technical Issues", description: "Bugs, crashes, and technical problems") - static let other = SupportFormArea(id: "other", title: "Other", description: "Something else not covered above") -} diff --git a/Modules/Sources/Support/SupportDataProvider.swift b/Modules/Sources/Support/SupportDataProvider.swift index 0ca6983418bd..42b72e7c4276 100644 --- a/Modules/Sources/Support/SupportDataProvider.swift +++ b/Modules/Sources/Support/SupportDataProvider.swift @@ -16,13 +16,6 @@ public enum SupportFormAction { case failToCreateBotConversation(Error) case failToReplyToBotConversation(Error) - case viewSupportTicketList - case viewSupportTicket(ticketId: UInt64) - case createSupportTicket - case replyToSupportTicket(ticketId: UInt64) - case failToCreateSupportTicket(Error) - case failToReplyToSupportTicket(Error) - case viewDiagnostics case emptyDiskCache(bytesSaved: Int64) } @@ -33,7 +26,6 @@ public final class SupportDataProvider: ObservableObject, Sendable { private let applicationLogProvider: ApplicationLogDataProvider private let botConversationDataProvider: BotConversationDataProvider private let userDataProvider: CurrentUserDataProvider - private let supportConversationDataProvider: SupportConversationDataProvider private let diagnosticsDataProvider: DiagnosticsDataProvider let mediaHost: MediaHostProtocol @@ -43,7 +35,6 @@ public final class SupportDataProvider: ObservableObject, Sendable { applicationLogProvider: ApplicationLogDataProvider, botConversationDataProvider: BotConversationDataProvider, userDataProvider: CurrentUserDataProvider, - supportConversationDataProvider: SupportConversationDataProvider, diagnosticsDataProvider: DiagnosticsDataProvider, mediaHost: MediaHostProtocol, delegate: SupportDelegate? = nil @@ -51,7 +42,6 @@ public final class SupportDataProvider: ObservableObject, Sendable { self.applicationLogProvider = applicationLogProvider self.botConversationDataProvider = botConversationDataProvider self.userDataProvider = userDataProvider - self.supportConversationDataProvider = supportConversationDataProvider self.diagnosticsDataProvider = diagnosticsDataProvider self.mediaHost = mediaHost self.supportDelegate = delegate @@ -104,61 +94,6 @@ public final class SupportDataProvider: ObservableObject, Sendable { } } - // Support Conversations Data Source - public func loadSupportConversations() throws -> any CachedAndFetchedResult<[ConversationSummary]> { - try self.supportConversationDataProvider.loadSupportConversations() - } - - public func loadSupportConversation(id: UInt64) throws -> any CachedAndFetchedResult { - try self.supportConversationDataProvider.loadSupportConversation(id: id) - } - - public func replyToSupportConversation( - id: UInt64, - message: String, - user: SupportUser, - attachments: [URL] - ) async throws -> Conversation { - self.userDid(.replyToSupportTicket(ticketId: id)) - - do { - return try await self.supportConversationDataProvider.replyToSupportConversation( - id: id, - message: message, - user: user, - attachments: attachments - ) - } catch { - self.userDid(.failToReplyToSupportTicket(error)) - throw error - } - } - - public func createSupportConversation( - subject: String, - message: String, - user: SupportUser, - attachments: [URL] - ) async throws -> Conversation { - self.userDid(.createSupportTicket) - - do { - return try await self.supportConversationDataProvider.createSupportConversation( - subject: subject, - message: message, - user: user, - attachments: attachments - ) - } catch { - self.userDid(.failToCreateSupportTicket(error)) - throw error - } - } - - var maximumUploadSize: CGFloat { - CGFloat(self.supportConversationDataProvider.maximumUploadSize) - } - // Application Logs public var canShareApplicationLogs: Bool { self.applicationLogProvider.canShareApplicationLogs @@ -194,41 +129,6 @@ public final class SupportDataProvider: ObservableObject, Sendable { } } -public protocol SupportFormDataProvider { - /// The user-selectable category - var areas: [SupportFormArea] { get } - - /// - var areasTitle: String { get } - - var formTitle: String { get } - - var formDescription: String { get } -} - -extension SupportFormDataProvider { - var areasTitle: String { - NSLocalizedString( - "I need help with", - comment: "Text on the support form to refer to what area the user has problem with." - ) - } - - var formTitle: String { - NSLocalizedString( - "Let’s get this sorted", - comment: "Title to let the user know what do we want on the support screen." - ) - } - - var formDescription: String { - NSLocalizedString( - "Let us know your site address (URL) and tell us as much as you can about the problem, and we will be in touch soon.", - comment: "Message info on the support screen." - ) - } -} - public protocol SupportDelegate: NSObject { func userDid(_ action: SupportFormAction) @@ -281,24 +181,3 @@ public protocol BotConversationDataProvider: Actor { func sendMessage(message: String, in conversation: BotConversation?) async throws -> BotConversation func delete(conversationIds: [UInt64]) async throws } - -public protocol SupportConversationDataProvider: Actor { - nonisolated var maximumUploadSize: UInt64 { get } - - nonisolated func loadSupportConversations() throws -> any CachedAndFetchedResult<[ConversationSummary]> - nonisolated func loadSupportConversation(id: UInt64) throws -> any CachedAndFetchedResult - - func replyToSupportConversation( - id: UInt64, - message: String, - user: SupportUser, - attachments: [URL] - ) async throws -> Conversation - - func createSupportConversation( - subject: String, - message: String, - user: SupportUser, - attachments: [URL] - ) async throws -> Conversation -} diff --git a/Modules/Sources/Support/UI/Support Conversations/ApplicationLogPicker.swift b/Modules/Sources/Support/UI/ApplicationLogPicker.swift similarity index 100% rename from Modules/Sources/Support/UI/Support Conversations/ApplicationLogPicker.swift rename to Modules/Sources/Support/UI/ApplicationLogPicker.swift diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift index 145b55672d77..723380c9ac7c 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift @@ -42,14 +42,6 @@ public struct ConversationView: View { } } - var userWantsHumanSupport: Bool { - switch self { - case .partiallyLoaded(let conversation, _): conversation.userWantsHumanSupport - case .loaded(conversation: let conversation, _): conversation.userWantsHumanSupport - default: false - } - } - var shouldCauseScrollToBottom: Bool { switch self { case .loaded, .partiallyLoaded: true @@ -282,8 +274,6 @@ public struct ConversationView: View { sendingMessageError - switchToHumanSupport - Text(verbatim: "").padding(.bottom, 4) .listRowInsets(.zero) .listRowBackground(Color.clear) @@ -375,38 +365,6 @@ public struct ConversationView: View { } } - @ViewBuilder - var switchToHumanSupport: some View { - - if state.userWantsHumanSupport { - Section { - // Deliberately left empty - } footer: { - if #available(iOS 26.0, *) { - openSupportTicketButton - .buttonStyle(.glassProminent) - } else { - openSupportTicketButton - .buttonStyle(.borderedProminent) - } - } - } - } - - @ViewBuilder - var openSupportTicketButton: some View { - NavigationLink { - SupportForm( - supportIdentity: self.currentUser - ).environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller - } label: { - Text(Localization.openSupportTicket) - .font(.headline) - .padding(.vertical) - .frame(maxWidth: .infinity) - } - } - private func scrollToBottom(using proxy: ScrollViewProxy, animated: Bool) { if animated { withAnimation { diff --git a/Modules/Sources/Support/UI/SingleVideoView.swift b/Modules/Sources/Support/UI/SingleVideoView.swift new file mode 100644 index 000000000000..8467e574953a --- /dev/null +++ b/Modules/Sources/Support/UI/SingleVideoView.swift @@ -0,0 +1,51 @@ +import SwiftUI +import AVKit +import AsyncImageKit + +struct SingleVideoView: View { + + @State + private var player: AVPlayer? = nil + + @State + private var error: Error? = nil + + private let url: URL + private let host: MediaHostProtocol? + + init(url: URL, host: MediaHostProtocol? = nil) { + self.url = url + self.host = host + } + + var body: some View { + Group { + if let player { + VideoPlayer(player: player) + .ignoresSafeArea() + .onAppear { + player.play() + } + } else if let error { + FullScreenErrorView( + title: Localization.unableToDisplayVideo, + message: error.localizedDescription, + systemImage: "film" + ) + } else { + FullScreenProgressView(Localization.loadingVideo) + } + }.task { + if let host { + do { + let asset = try await host.authenticatedAsset(for: url) + self.player = AVPlayer(playerItem: AVPlayerItem(asset: asset)) + } catch { + self.error = error + } + } else { + self.player = AVPlayer(url: url) + } + } + } +} diff --git a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift deleted file mode 100644 index 5270a0ba2f2e..000000000000 --- a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift +++ /dev/null @@ -1,273 +0,0 @@ -import SwiftUI -import AsyncImageKit -import PDFKit -import AVKit - -struct SingleImageView: View { - - let url: URL - - @GestureState private var currentZoom = 1.0 - - var magnification: some Gesture { - MagnifyGesture().updating($currentZoom, body: { newValue, state, _ in - state = newValue.magnification - }) - } - - var body: some View { - CachedAsyncImage(url: url) { image in - image - .resizable() - .aspectRatio(contentMode: .fit) - .scaleEffect(currentZoom) - .scaledToFit() - .gesture(magnification) - } placeholder: { - ProgressView(Localization.loadingImage) - } - .navigationTitle(url.lastPathComponent) - } -} - -struct SingleVideoView: View { - - @State - private var player: AVPlayer? = nil - - @State - private var error: Error? = nil - - private let url: URL - private let host: MediaHostProtocol? - - init(url: URL, host: MediaHostProtocol? = nil) { - self.url = url - self.host = host - } - - var body: some View { - Group { - if let player { - VideoPlayer(player: player) - .ignoresSafeArea() - .onAppear { - player.play() - } - } else if let error { - FullScreenErrorView( - title: Localization.unableToDisplayVideo, - message: error.localizedDescription, - systemImage: "film" - ) - } else { - FullScreenProgressView(Localization.loadingVideo) - } - }.task { - if let host { - do { - let asset = try await host.authenticatedAsset(for: url) - self.player = AVPlayer(playerItem: AVPlayerItem(asset: asset)) - } catch { - self.error = error - } - } else { - self.player = AVPlayer(url: url) - } - } - } -} - -struct SinglePDFView: UIViewRepresentable { - let url: URL // Or Data for in-memory PDFs - - func makeUIView(context: Context) -> PDFView { - let pdfView = PDFView() - if let document = PDFDocument(url: url) { - pdfView.document = document - } - return pdfView - } - - func updateUIView(_ uiView: PDFView, context: Context) { - // Update the view if the URL or other properties change - if let document = PDFDocument(url: url) { - uiView.document = document - } - } -} - -struct AttachmentListView: View { - let attachments: [Attachment] - - @State private var selectedAttachment: Attachment? - - private let columns = [ - GridItem(.adaptive(minimum: 80, maximum: 120), spacing: 8) - ] - - private var imageAttachments: [Attachment] { - attachments.filter { $0.isImage || $0.isVideo } - } - - private var otherAttachments: [Attachment] { - attachments.filter { !$0.isImage && !$0.isVideo } - } - - var body: some View { - VStack(alignment: .leading) { - LazyVGrid(columns: columns, spacing: 16) { - ForEach(imageAttachments) { attachment in - AttachmentThumbnailView(attachment: attachment) - } - } - - ForEach(otherAttachments) { attachment in - AttachmentRowView(attachment: attachment) - } - } - } -} - -struct AttachmentThumbnailView: View { - - @EnvironmentObject - private var supportDataProvider: SupportDataProvider - - let attachment: Attachment - - var body: some View { - NavigationLink { - if attachment.isImage { - SingleImageView(url: attachment.url) - } - - if attachment.isVideo { - SingleVideoView(url: attachment.url, host: supportDataProvider.mediaHost) - } - } label: { - ZStack { - if attachment.isImage { - CachedAsyncImage(url: attachment.url, host: supportDataProvider.mediaHost, mutability: .immutable) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - Color.gray.opacity(0.2).overlay { - ProgressView() - } - } - } - - if attachment.isVideo { - CachedAsyncImage( - videoUrl: attachment.url, - host: supportDataProvider.mediaHost, - mutability: .immutable - ) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - .overlay { - Image(systemName: "play.circle") - .foregroundStyle(Color.white) - } - } placeholder: { - Color.gray.opacity(0.2).overlay { - ProgressView() - } - } - } - } - .frame(width: 80, height: 80) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - } -} - -struct AttachmentRowView: View { - - let attachment: Attachment - - var body: some View { - NavigationLink { - if attachment.isPdf { - SinglePDFView(url: attachment.url) - .navigationTitle(attachment.filename) - } - } label: { - HStack(alignment: .firstTextBaseline) { - Image(systemName: attachment.icon) - .foregroundColor(.secondary) - .font(.body) - .frame(width: 40, height: 40) - Text(attachment.filename) - .font(.body) - .foregroundColor(.secondary) - .lineLimit(1) - Spacer() - } - .background(Color(UIColor.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .padding(.bottom, 4) - } - } -} - -typealias ImageUrl = String - -extension ImageUrl: @retroactive Identifiable { - public var id: String { - self - } - - var filename: String { - self.url.lastPathComponent - } - - var url: URL { - URL(string: self)! - } -} - -#Preview { - - let images = [ - "https://picsum.photos/seed/1/800/600", - "https://picsum.photos/seed/2/800/600", - "https://picsum.photos/seed/3/800/600", - "https://picsum.photos/seed/4/800/600", - "https://picsum.photos/seed/5/800/600", - ].map { ImageUrl($0) }.map { Attachment( - id: .random(in: 0...UInt64.max), - filename: $0.filename, - contentType: "image/jpeg", - fileSize: 123456, - url: $0.url - ) } - - let documents = [ - "https://www.rd.usda.gov/sites/default/files/pdf-sample_0.pdf" - ].map { ImageUrl($0) }.map { Attachment( - id: .random(in: 0...UInt64.max), - filename: $0.filename, - contentType: "application/pdf", - fileSize: 45678, - url: $0.url - )} - - let videos = [ - "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4" - ].map { ImageUrl($0) }.map { Attachment( - id: .random(in: 0...UInt64.max), - filename: "file_example_MP4_1920_18MG.mp4", - contentType: "video/mp4", - fileSize: 99842342, - url: $0.url - )} - - NavigationStack { - AttachmentListView(attachments: images + documents + videos) - }.environmentObject(SupportDataProvider.testing) -} diff --git a/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift b/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift deleted file mode 100644 index ccb3857e721e..000000000000 --- a/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift +++ /dev/null @@ -1,241 +0,0 @@ -import SwiftUI -import PhotosUI - -struct ScreenshotPicker: View { - - enum ViewState: Sendable { - case ready - case loading - case error(Error) - - var isLoadingMoreImages: Bool { - guard case .loading = self else { return false } - return true - } - - var error: Error? { - guard case .error(let error) = self else { return nil } - return error - } - } - - private let maxScreenshots = 10 - - @State - private var selectedPhotos: [PhotosPickerItem] = [] - - @State - private var attachedImages: [UIImage] = [] - - @State - private var state: ViewState = .ready - - @Binding - var attachedImageUrls: [URL] - - @State - private var currentUploadSize: CGFloat = 0 - - let maximumUploadSize: CGFloat? - - @Binding - var uploadLimitExceeded: Bool - - var body: some View { - Section { - Text(Localization.screenshotsDescription) - .font(.body) - .foregroundColor(.secondary) - - if let error = self.state.error { - ErrorView( - title: "Unable to load screenshot", - message: error.localizedDescription - ) - } - - if !attachedImages.isEmpty { - imageGallery - maxSizeIndicator - } - - // Add screenshots button - PhotosPicker( - selection: $selectedPhotos, - maxSelectionCount: maxScreenshots, - matching: .any(of: [ - .screenshots, - .screenRecordings - ]) - ) { [imageCount = attachedImages.count, isLoading = self.state.isLoadingMoreImages, uploadLimitExceeded = self.uploadLimitExceeded] in - HStack { - if isLoading { - ProgressView() - .tint(Color.accentColor) - } else { - Image(systemName: "camera.fill") - } - - Text(imageCount == 0 ? Localization.addScreenshots : Localization.addMoreScreenshots) - } - .frame(maxWidth: .infinity) - .padding() - .background(Color.accentColor.opacity(0.1)) - .foregroundStyle(uploadLimitExceeded ? Color.gray : Color.accentColor) - .cornerRadius(8) - } - .onChange(of: selectedPhotos) { _, newItems in - Task { - self.state = .loading - await loadSelectedPhotos(newItems) - self.state = .ready - } - } - .disabled(uploadLimitExceeded) - } header: { - HStack { - Text(Localization.screenshots) - Text(Localization.optional) - .font(.caption) - .foregroundColor(.secondary) - } - } - .listRowSeparator(.hidden) - .selectionDisabled() - } - - @ViewBuilder - var imageGallery: some View { - // Screenshots display - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(spacing: 12) { - ForEach(Array(attachedImages.enumerated()), id: \.offset) { index, image in - ZStack(alignment: .topTrailing) { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 80, height: 80) - .clipped() - .cornerRadius(8) - - // Remove button - Button { - // attachedImages will be updated by changing `selectedPhotos`, but not immediately. This line is here to make the UI feel snappy - attachedImages.remove(at: index) - selectedPhotos.remove(at: index) - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.red) - .background(Color.white, in: Circle()) - } - .padding(4) - } - } - } - .padding(.horizontal, 2) - } - } - - @ViewBuilder - var maxSizeIndicator: some View { - if let maximumUploadSize { - VStack(alignment: .leading) { - ProgressView(value: currentUploadSize, total: maximumUploadSize) - .tint(uploadLimitExceeded ? Color.red : Color.accentColor) - - Text(String.localizedStringWithFormat(Localization.attachmentLimit, format(bytes: currentUploadSize), format(bytes: maximumUploadSize))) - .font(.caption2) - .foregroundStyle(Color.secondary) - } - } - } - - private func format(bytes: CGFloat) -> String { - ByteCountFormatter().string(fromByteCount: Int64(bytes)) - } - - /// Loads selected photos from PhotosPicker - @MainActor - func loadSelectedPhotos(_ items: [PhotosPickerItem]) async { - var newImages: [UIImage] = [] - var newUrls: [URL] = [] - var totalSize: CGFloat = 0 - - do { - for item in items { - if let data = try await item.loadTransferable(type: Data.self) { - if let image = UIImage(data: data) { - newImages.append(image) - } - - totalSize += CGFloat(data.count) - } - - if let file = try await item.loadTransferable(type: ScreenshotFile.self) { - newUrls.append(file.url) - } - } - - self.attachedImages = newImages - self.attachedImageUrls = newUrls - - withAnimation { - self.currentUploadSize = totalSize - self.uploadLimitExceeded = totalSize > maximumUploadSize ?? .infinity - } - } catch { - withAnimation { - self.state = .error(error) - } - } - } -} - -/// File representation -struct ScreenshotFile: Transferable { - let url: URL - - var filename: String { - url.lastPathComponent - } - - private static let cacheDirectoryName: String = "screenshot-cache" - - static var transferRepresentation: some TransferRepresentation { - FileRepresentation(contentType: .image) { - return SentTransferredFile($0.url) - } importing: { received in - let directory = URL.cachesDirectory - .appendingPathComponent(cacheDirectoryName) - .appendingPathComponent(UUID().uuidString) - - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - - let destination = directory.appendingPathComponent(received.file.lastPathComponent) - - try FileManager.default.copyItem(at: received.file, to: destination) - - return Self(url: destination) - } - } -} - -#Preview { - struct Preview: View { - @State - var selectedPhotoUrls: [URL] = [] - - var body: some View { - Form { - ScreenshotPicker( - attachedImageUrls: $selectedPhotoUrls, - maximumUploadSize: 10_000_000, - uploadLimitExceeded: .constant(false) - ) - } - .environmentObject(SupportDataProvider.testing) - } - } - - return Preview() -} diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift deleted file mode 100644 index b3299f2a7f9a..000000000000 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift +++ /dev/null @@ -1,229 +0,0 @@ -import SwiftUI - -@MainActor -public struct SupportConversationListView: View { - - enum ViewState: Equatable { - case start - case loading(Task) - case partiallyLoaded([ConversationSummary], Task) - case loaded([ConversationSummary]) - case error(String) - - var isPartiallyLoaded: Bool { - guard case .partiallyLoaded = self else { - return false - } - - return true - } - } - - @EnvironmentObject - private var dataProvider: SupportDataProvider - - @State - private var state: ViewState = .start - - @State - private var isComposingNewMessage: Bool = false - - private let currentUser: SupportUser - - public init(currentUser: SupportUser) { - self.currentUser = currentUser - } - - public var body: some View { - Group { - switch self.state { - case .start, .loading: - FullScreenProgressView(Localization.loadingConversations) - case .partiallyLoaded(let conversations, _), .loaded(let conversations): - self.conversationsList(conversations) - case .error(let error): - FullScreenErrorView( - title: Localization.errorLoadingSupportConversations, - message: error - ) - } - } - .navigationTitle(Localization.supportConversations) - .navigationBarTitleDisplayMode(.large) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - self.isComposingNewMessage = true - } - label: { - Image(systemName: "square.and.pencil") - } - } - } - .sheet(isPresented: self.$isComposingNewMessage, content: { - NavigationStack { - SupportForm(supportIdentity: self.currentUser) { - self.reloadConversations() - } - }.environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller - }) - .overlay { - OverlayProgressView(shouldBeVisible: self.state.isPartiallyLoaded) - } - .onAppear { - self.dataProvider.userDid(.viewSupportTicketList) - } - .task(self.loadConversations) - .refreshable(action: self.reloadConversations) - } - - @ViewBuilder - func conversationsList(_ conversations: [ConversationSummary]) -> some View { - List { - ForEach(conversations) { conversation in - NavigationLink { - SupportConversationView( - conversation: conversation, - currentUser: currentUser - ).environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller - - } label: { - EmailRowView(conversation: conversation) - } - } - } - .listStyle(PlainListStyle()) - .listRowInsets(.zero) - .listRowSeparator(.hidden) - } - - @MainActor - private func loadConversations() { - guard case .start = self.state else { - return - } - - self.state = .loading(self.cacheTask) - } - - @MainActor - private func reloadConversations() { - guard case .loaded(let conversations) = state else { - return - } - - self.state = .partiallyLoaded(conversations, self.fetchTask) - } - - private var cacheTask: Task { - Task { - do { - let fetch = try dataProvider.loadSupportConversations() - - if let cachedResults = try await fetch.cachedResult() { - self.state = .partiallyLoaded(cachedResults, self.fetchTask) - } - - let fetchedResults = try await fetch.fetchedResult() - self.state = .loaded(fetchedResults) - } catch { - self.state = .error(error.localizedDescription) - } - } - } - - private var fetchTask: Task { - Task { - do { - let fetch = try dataProvider.loadSupportConversations() - let conversations = try await fetch.fetchedResult() - self.state = .loaded(conversations) - } catch { - self.state = .error(error.localizedDescription) - } - } - } -} - -// MARK: - Email Row View -struct EmailRowView: View { - - @Environment(\.sizeCategory) - private var sizeCategory - - let conversation: ConversationSummary - - var body: some View { - VStack(alignment: .leading) { - VStack { - header - - HStack { - TimelineView(.periodic(from: .now, by: 1.0)) { _ in - Text(formatTimestamp(conversation.lastMessageSentAt)) - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - } - }.padding(.bottom, 2) - - Text(conversation.plainTextDescription) - .font(.body) - .foregroundColor(.secondary) - .lineLimit(2) - .multilineTextAlignment(.leading) - } - } - - @ViewBuilder - var header: some View { - if self.sizeCategory.isAccessibilityCategory { - VStack { - HStack { - Text(conversation.title) - .font(.headline) - .foregroundColor(.primary) - .lineLimit(2) - Spacer() - } - - HStack { - ChipView( - string: conversation.status.title, - color: conversation.status.color - ).controlSize(.mini) - Spacer() - } - } - } else { - HStack { - Text(conversation.title) - .font(.headline) - .foregroundColor(.primary) - .lineLimit(1) - - Spacer() - - ChipView( - string: conversation.status.title, - color: conversation.status.color - ).controlSize(.mini) - } - } - } - - private func formatTimestamp(_ date: Date) -> String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter.localizedString(for: date, relativeTo: Date()) - } -} - -#Preview { - NavigationStack { - SupportConversationListView( - currentUser: SupportDataProvider.supportUser - ) - }.environmentObject(SupportDataProvider.testing) -} diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift deleted file mode 100644 index 252324bac31c..000000000000 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift +++ /dev/null @@ -1,287 +0,0 @@ -import SwiftUI -import PhotosUI - -public struct SupportConversationReplyView: View { - - private let enableRichTextForm: Bool = false - - enum ViewState: Equatable { - case editing - case sending(Task) - case sent(Task) - case error(String) - - var isSendingMessage: Bool { - guard case .sending = self else { return false } - return true - } - - var messageWasSent: Bool { - guard case .sent = self else { return false } - return true - } - - var isError: Bool { - guard case .error = self else { return false } - return true - } - - var error: String { - guard case .error(let string) = self else { - return "" - } - - return string - } - - var cancelButtonShouldBeDisabled: Bool { - if case .sending = self { - return true - } - - return false - } - } - - let conversation: Conversation - let currentUser: SupportUser - let conversationDidUpdate: (Conversation) -> Void - - @Environment(\.dismiss) - private var dismiss - - @EnvironmentObject - var dataProvider: SupportDataProvider - - @State - private var richText: AttributedString = "" - - @State - private var plainText: String = "" - - @State - private var state: ViewState = .editing - - @State - private var isDisplayingCancellationConfirmation: Bool = false - - @FocusState - private var isTextFieldFocused: Bool - - @State private var selectedPhotos: [URL] = [] - @State private var uploadLimitExceeded: Bool = false - - @State - private var includeApplicationLogs: Bool = false - - private var textIsEmpty: Bool { - plainText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && String(richText.characters).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - private var canSendMessage: Bool { - !textIsEmpty && state == .editing && !uploadLimitExceeded - } - - public init(conversation: Conversation, currentUser: SupportUser, conversationDidUpdate: @escaping (Conversation) -> Void) { - self.conversation = conversation - self.currentUser = currentUser - self.conversationDidUpdate = conversationDidUpdate - } - - public var body: some View { - VStack { - Form { - Section(Localization.message) { - textEditor - } - - ScreenshotPicker( - attachedImageUrls: self.$selectedPhotos, - maximumUploadSize: self.dataProvider.maximumUploadSize, - uploadLimitExceeded: self.$uploadLimitExceeded - ) - - ApplicationLogPicker( - includeApplicationLogs: self.$includeApplicationLogs - ) - } - } - .scrollDismissesKeyboard(.interactively) - .interactiveDismissDisabled(!self.textIsEmpty) // Don't allow swiping down to dismiss if the user would lose data - .navigationTitle(Localization.reply) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(Localization.cancel) { - dismiss() - } - .disabled(self.state.cancelButtonShouldBeDisabled) - } - - ToolbarItem(placement: .confirmationAction) { - Button { - self.sendReply() - } label: { - Text(Localization.send) - } - .disabled(!canSendMessage) - } - } - .overlay { - ZStack { - ProgressView(Localization.sendingMessage) - .padding() - .background(Color(UIColor.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .shadow(radius: 8) - .opacity(state.isSendingMessage ? 1.0 : 0.0) - .offset(x: 0, y: state.isSendingMessage ? 0 : 20) - - ErrorView( - title: Localization.unableToSendMessage, - message: state.error - ) - .padding() - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .shadow(radius: 8) - .opacity(state.isError ? 1.0 : 0.0) - .offset(x: 0, y: state.isError ? 0 : 20) - .onTapGesture { - self.state = .editing - } - - VStack { - HStack { - Image(systemName: "checkmark.circle") - .font(.system(size: 48)) - .foregroundStyle(Color.gray) - .padding(.top, -4) - .padding(.bottom, 4) - } - Text(Localization.messageSent).font(.title2).bold() - } - .padding() - .background(Color(UIColor.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .shadow(radius: 8) - .opacity(state.messageWasSent ? 1.0 : 0.0) - .offset(x: 0, y: state.messageWasSent ? 0 : 20) - .onTapGesture { - self.dismiss() - } - } - } - .onAppear { - isTextFieldFocused = true - } - .alert( - Localization.confirmCancellation, - isPresented: $isDisplayingCancellationConfirmation, - actions: { - Button(Localization.discardChanges, role: .destructive) { - self.dismiss() - } - - Button(Localization.continueWriting, role: .cancel) { - self.isDisplayingCancellationConfirmation = false - } - }, message: { - Text(Localization.confirmCancelMessage) - } - ) - } - - @ViewBuilder - var textEditor: some View { - if #available(iOS 26.0, *), enableRichTextForm { - TextEditor(text: $richText) - .focused($isTextFieldFocused) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(minHeight: 120) - .disabled(state != .editing) - } else { - TextEditor(text: $plainText) - .focused($isTextFieldFocused) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(minHeight: 120) - .disabled(state != .editing) - } - } - - private func getText() throws -> String { - if #available(iOS 26.0, *), enableRichTextForm { - return self.richText.toHtml() - } else { - return self.plainText.trimmingCharacters(in: .whitespacesAndNewlines) - } - } - - private func sendReply() { - guard !textIsEmpty else { return } - - withAnimation { - state = .sending(self.sendingTask) - } - } - - var sendingTask: Task { - Task { - do { - let text = try getText() - - let conversation = try await dataProvider.replyToSupportConversation( - id: conversation.id, - message: text, - user: self.currentUser, - attachments: self.selectedPhotos - ) - - self.conversationDidUpdate(conversation) - - withAnimation { - state = .sent(Task { - // Display the sent message for 2 seconds, then auto-dismiss - try? await Task.sleep(for: .seconds(2)) - - dismiss() - }) - } - } catch { - state = .error(error.localizedDescription) - - if case .error = state { - state = .editing - } - } - } - } - - private func formatTimestamp(_ date: Date) -> String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter.localizedString(for: date, relativeTo: Date()) - } -} - -// MARK: - Application Log Row Component - -#Preview { - - @Previewable @State - var isPresented: Bool = true - - NavigationStack { - Text("Hello World").onTapGesture { - isPresented = true - } - }.sheet(isPresented: $isPresented) { - NavigationStack { - SupportConversationReplyView( - conversation: SupportDataProvider.supportConversation, - currentUser: SupportDataProvider.supportUser, conversationDidUpdate: { _ in } - ) - } - } - .environmentObject(SupportDataProvider.testing) -} diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift deleted file mode 100644 index cda07720ef86..000000000000 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift +++ /dev/null @@ -1,322 +0,0 @@ -import SwiftUI -import AsyncImageKit - -public struct SupportConversationView: View { - - enum ViewState: Equatable { - case start - case loading(cacheLoadTask: Task) - case partiallyLoaded(Conversation, fetchTask: Task) - case loaded(Conversation) - case error(String) - - var isPartiallyLoaded: Bool { - guard case .partiallyLoaded = self else { - return false - } - - return true - } - - var conversation: Conversation? { - switch self { - case .start: nil - case .loading: nil - case .partiallyLoaded(let conversation, _): conversation - case .loaded(let conversation): conversation - case .error: nil - } - } - - var canAcceptReply: Bool { - conversation?.canAcceptReply ?? false - } - } - - @EnvironmentObject - private var dataProvider: SupportDataProvider - - @State - private var state: ViewState = .start - - @State - private var isReplying: Bool = false - - @Namespace - var bottom - - private let conversationSummary: ConversationSummary - - private let currentUser: SupportUser - - private var canReply: Bool { - // Don't enable the new conversation button if the user isn't eligible for it - guard currentUser.permissions.contains(.createSupportRequest) else { - return false - } - - // Only allow replying once the conversation is fully loaded - guard case .loaded(let conversation) = state else { - return false - } - - return conversation.canAcceptReply - } - - public init( - conversation: ConversationSummary, - currentUser: SupportUser - ) { - self.currentUser = currentUser - self.conversationSummary = conversation - } - - public var body: some View { - VStack(spacing: 0) { - switch self.state { - case .start, .loading: - FullScreenProgressView(Localization.loadingMessages) - case .partiallyLoaded(let conversation, _), .loaded(let conversation): - self.conversationView(conversation) - case .error(let error): - FullScreenErrorView( - title: Localization.unableToDisplayConversation, - message: error - ) - } - } - .task(self.loadConversation) - .navigationTitle(self.conversationSummary.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - if self.state.canAcceptReply { - ToolbarItemGroup(placement: .primaryAction) { - Button { - self.isReplying = true - } label: { - Image(systemName: "arrowshape.turn.up.left") - } - .disabled(!canReply) - } - } - } - .overlay { - OverlayProgressView(shouldBeVisible: self.state.isPartiallyLoaded) - } - .sheet(isPresented: $isReplying) { - if case .loaded(let conversation) = state { - NavigationStack { - SupportConversationReplyView( - conversation: conversation, - currentUser: currentUser, - conversationDidUpdate: { conversation in - withAnimation { - self.state = .partiallyLoaded(conversation, fetchTask: self.fetchTask) - } - } - ) - } - .environmentObject(dataProvider) - } - } - .onAppear { - self.dataProvider.userDid(.viewSupportTicket(ticketId: conversationSummary.id)) - } - } - - @ViewBuilder - private func conversationView(_ conversation: Conversation) -> some View { - // Conversation header - conversationHeader(conversation) - - Divider() - - // Messages list - ScrollViewReader { proxy in - ScrollView { - LazyVStack { - ForEach(conversation.messages, id: \.id) { message in - MessageRowView( - message: message - ) - } - - if conversation.canAcceptReply { - Button { - self.isReplying = true - } label: { - Spacer() - HStack(alignment: .firstTextBaseline) { - Image(systemName: "arrowshape.turn.up.left") - Text(Localization.reply) - }.padding(.vertical, 8) - Spacer() - } - .padding() - .buttonStyle(BorderedProminentButtonStyle()) - .disabled(!canReply) - } else { - Text(Localization.conversationEnded) - .font(.caption) - .foregroundStyle(Color.secondary) - .padding(.top) - } - - Divider() - .opacity(0) - .id(self.bottom) - } - } - .background(Color(UIColor.systemGroupedBackground)) - .onAppear { - scrollToBottom(proxy: proxy) - } - .onChange(of: conversation.messages.count) { - scrollToBottom(proxy: proxy) - } - .refreshable(action: self.reloadConversation) - } - } - - @ViewBuilder - private func conversationHeader(_ conversation: Conversation) -> some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - ChipView( - string: conversation.status.title, - color: conversation.status.color - ).controlSize(.small) - - Spacer() - - HStack(spacing: 0) { - Text(lastUpdatedString) - .font(.caption2) - .foregroundColor(.secondary) - } - } - } - .padding() - } - - @MainActor - private func scrollToBottom(proxy: ScrollViewProxy) { - guard case .loaded = state else { - return - } - - withAnimation(.easeInOut(duration: 0.3)) { - proxy.scrollTo(self.bottom, anchor: .bottom) - } - } - - private func messageCountString(_ conversation: Conversation) -> String { - return String(format: Localization.messagesCount, conversation.messages.count) - } - - private var lastUpdatedString: String { - let timestamp = formatTimestamp(conversationSummary.lastMessageSentAt) - return String(format: Localization.lastUpdated, timestamp) - } - - private func formatTimestamp(_ date: Date) -> String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter.localizedString(for: date, relativeTo: Date()) - } - - @MainActor - private func loadConversation() async { - guard case .start = state else { - return - } - - self.state = .loading(cacheLoadTask: self.cacheTask) - } - - @MainActor - private func reloadConversation() async { - guard case .loaded(let conversation) = state else { - return - } - - self.state = .partiallyLoaded(conversation, fetchTask: fetchTask) - } - - private var cacheTask: Task { - Task { - do { - let id = self.conversationSummary.id - if let conversation = try await self.dataProvider.loadSupportConversation(id: id).cachedResult() { - self.state = .partiallyLoaded(conversation, fetchTask: self.fetchTask) - } else { - await self.fetchTask.value - } - } catch { - self.state = .error(error.localizedDescription) - } - } - } - - private var fetchTask: Task { - Task { - do { - let id = self.conversationSummary.id - let conversation = try await self.dataProvider.loadSupportConversation(id: id).fetchedResult() - self.state = .loaded(conversation) - } catch { - self.state = .error(error.localizedDescription) - } - } - } -} - -struct MessageRowView: View { - let message: Message - - var body: some View { - VStack(alignment: .leading) { - HStack { - VStack(alignment: .leading) { - HStack { - Text(message.authorName) - .font(.caption.weight(.semibold)) - .foregroundColor(message.authorIsUser ? .accentColor : .secondary) - - Spacer() - - Text(message.createdAt, style: .time) - .font(.caption2) - .foregroundColor(.secondary) - }.padding(.bottom) - - // Message content - Text(message.attributedContent) - .font(.body) - .foregroundColor(.primary) - .textSelection(.enabled) - - // Attachments (if any) - if !message.attachments.isEmpty { - AttachmentListView(attachments: message.attachments) - } - } - .padding() - .background( - message.authorIsUser ? Color.accentColor.opacity(0.10) : - Color(UIColor.systemGray5)) - } - } - .id(message.id) - } -} - -#Preview { - NavigationStack { - SupportConversationView( - conversation: SupportDataProvider.supportConversationSummaries.first!, - currentUser: SupportDataProvider.supportUser - ) - } - .environmentObject(SupportDataProvider.testing) -} diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift deleted file mode 100644 index ef112e1754ae..000000000000 --- a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift +++ /dev/null @@ -1,445 +0,0 @@ -import Foundation -import SwiftUI -import PhotosUI - -public struct SupportForm: View { - - private let enableRichTextForm: Bool = false - - @EnvironmentObject - private var dataProvider: SupportDataProvider - - @Environment(\.dismiss) - private var dismiss - - /// Focus state for managing field focus - @FocusState private var focusedField: Field? - - /// Available support areas for the user to choose from - let areas: [SupportFormArea] = [ - .application, - .jetpackConnection, - .siteManagement, - .billing, - .technical, - .other - ] - - /// Variable that holds the area of support for better routing. - @State private var selectedArea: SupportFormArea? - - /// Variable that holds the subject of the ticket. - @State private var subject = "" - - /// Variable that holds the site address of the ticket. - @State private var siteAddress = "" - - /// Variable that holds the description of the ticket. - @State private var plainTextProblemDescription = "" - @State private var attributedProblemDescription: AttributedString = "" - - /// User's contact information - private let supportIdentity: SupportUser - - /// Application Logs - @State private var includeApplicationLogs = false - @State private var applicationLogs: [ApplicationLog] - - @State private var selectedPhotos: [URL] = [] - @State private var uploadLimitExceeded = false - - /// UI State - @State private var showLoadingIndicator = false - @State private var shouldShowErrorAlert = false - @State private var shouldShowSuccessAlert = false - @State private var errorMessage = "" - @State private var isDisplayingCancellationConfirmation: Bool = false - - /// Callback for when form is dismissed - public var onDismiss: (() -> Void)? - - private var subjectIsEmpty: Bool { - subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - private var problemDescriptionIsEmpty: Bool { - plainTextProblemDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && NSAttributedString(attributedProblemDescription).string - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty - } - - /// Determines if the submit button should be enabled or not. - private var submitButtonDisabled: Bool { - selectedArea == nil - || subjectIsEmpty - || problemDescriptionIsEmpty - || uploadLimitExceeded - } - - /// Determines if the user has unsaved changes – if they do, we won't allow dismissing the form - /// without prompting the user first. - private var userHasUnsavedChanges: Bool { - !subjectIsEmpty || !problemDescriptionIsEmpty - } - - public init( - supportIdentity: SupportUser, - applicationLogs: [ApplicationLog] = [], - onDismiss: (() -> Void)? = nil - ) { - self.onDismiss = onDismiss - self.supportIdentity = supportIdentity - self.applicationLogs = applicationLogs - } - - public var body: some View { - Form { - // Support Area Selection - supportAreaSection - - // Issue Details Section - issueDetailsSection - - // Screenshots Section - ScreenshotPicker( - attachedImageUrls: $selectedPhotos, - maximumUploadSize: self.dataProvider.maximumUploadSize, - uploadLimitExceeded: self.$uploadLimitExceeded - ) - - // Application Logs Section - ApplicationLogPicker( - includeApplicationLogs: $includeApplicationLogs - ) - - // Contact Information Section - contactInformationSection - - // Submit Button Section - submitButtonSection - } - .scrollDismissesKeyboard(.interactively) - .interactiveDismissDisabled(self.userHasUnsavedChanges) - .navigationTitle(Localization.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(Localization.cancel, role: .cancel) { - if self.userHasUnsavedChanges { - self.isDisplayingCancellationConfirmation = true - } else { - self.onDismiss?() - self.dismiss() - } - } - } - } - .alert( - Localization.confirmCancellation, - isPresented: $isDisplayingCancellationConfirmation, - actions: { - Button(Localization.discardChanges, role: .destructive) { - self.dismiss() - } - - Button(Localization.continueWriting, role: .cancel) { - self.isDisplayingCancellationConfirmation = false - } - }, message: { - Text(Localization.confirmCancelMessage) - } - ) - .alert(Localization.errorTitle, isPresented: $shouldShowErrorAlert) { - Button(Localization.gotIt) { - shouldShowErrorAlert = false - } - } message: { - Text(errorMessage) - } - .alert(Localization.supportRequestSent, isPresented: $shouldShowSuccessAlert) { - Button(Localization.gotIt) { - shouldShowSuccessAlert = false - onDismiss?() - self.dismiss() - } - } message: { - Text(Localization.supportRequestSentMessage) - } - } -} - -// MARK: - View Sections -private extension SupportForm { - - /// Support area selection section - @ViewBuilder - var supportAreaSection: some View { - Group { - Section { - } header: { - Text(Localization.iNeedHelp) - } footer: { - VStack { - ForEach(areas, id: \.id) { area in - SupportAreaRow( - area: area, - isSelected: isAreaSelected(area) - ) { - selectArea(area) - } - } - }.listRowInsets(.zero) - } - }.padding(.bottom, 10) - } - - /// Contact information section - @ViewBuilder - var contactInformationSection: some View { - Section { - VStack(alignment: .leading) { - Text(Localization.emailNotice) - .font(.caption) - .foregroundColor(.secondary) - - ProfileView(user: supportIdentity) - } - } header: { - Text(Localization.contactInformation) - } - .listRowSeparator(.hidden) - .listRowSpacing(0) - } - - /// Issue details section - @ViewBuilder - var issueDetailsSection: some View { - Section { - // Subject field - VStack(alignment: .leading) { - Text(Localization.subject) - .onTapGesture { focusedField = .subject } - - TextField(Localization.subjectPlaceholder, text: $subject) - .focused($focusedField, equals: .subject) - } - - // Site Address field (optional) - VStack(alignment: .leading) { - Text(Localization.siteAddress + " " + Localization.optional) - .onTapGesture { focusedField = .siteAddress } - - TextField(Localization.siteAddressPlaceholder, text: $siteAddress) - .multilineTextAlignment(.leading) - .keyboardType(.URL) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .focused($focusedField, equals: .siteAddress) - } - } header: { - Text(Localization.issueDetails) - } - - Section(Localization.message) { - textEditor - } - } - - @ViewBuilder - var textEditor: some View { - if #available(iOS 26.0, *), self.enableRichTextForm { - TextEditor(text: $attributedProblemDescription) - .focused($focusedField, equals: .problemDescription) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(minHeight: 120) - } else { - TextEditor(text: $plainTextProblemDescription) - .focused($focusedField, equals: .problemDescription) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(minHeight: 120) - } - } - - /// Submit button section - @ViewBuilder - var submitButtonSection: some View { - Section { - Button { - submitSupportRequest() - } label: { - HStack { - if showLoadingIndicator { - ProgressView().tint(Color.white) - } - Text(Localization.submitRequest) - .fontWeight(.medium) - .padding(.vertical, 8) - } - .frame(maxWidth: .infinity) - } - .disabled(submitButtonDisabled || showLoadingIndicator) - .buttonStyle(.borderedProminent) - .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) - .listRowBackground(Color.clear) - .listRowSpacing(0) - } - .background(Color.clear) - .listRowSeparator(.hidden) - } -} - -// MARK: - Helper Methods -private extension SupportForm { - - /// Selects a support area - func selectArea(_ area: SupportFormArea) { - selectedArea = area - } - - /// Determines if the given area is selected - func isAreaSelected(_ area: SupportFormArea) -> Bool { - selectedArea == area - } - - private func getText() throws -> String { - if #available(iOS 26.0, *), self.enableRichTextForm { - return self.attributedProblemDescription.toHtml() - } else { - return self.plainTextProblemDescription.trimmingCharacters(in: .whitespacesAndNewlines) - } - } - - /// Submits the support request - @MainActor - func submitSupportRequest() { - guard !submitButtonDisabled else { return } - - showLoadingIndicator = true - - Task { - do { - _ = try await self.dataProvider.createSupportConversation( - subject: self.subject, - message: self.getText(), - user: self.supportIdentity, - attachments: self.selectedPhotos - ) - - showLoadingIndicator = false - shouldShowSuccessAlert = true - } catch { - showLoadingIndicator = false - errorMessage = error.localizedDescription - shouldShowErrorAlert = true - } - } - } - - /// Formats dates for display - func format(date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .short - return formatter.string(from: date) - } -} - -// MARK: - Field Focus Management -private extension SupportForm { - /// Enum for managing field focus states - enum Field: Hashable { - case fullName - case emailAddress - case subject - case siteAddress - case problemDescription - } -} - -// MARK: - Support Area Row Component -struct SupportAreaRow: View { - let area: SupportFormArea - let isSelected: Bool - let action: () -> Void - - var body: some View { - HStack(alignment: .top, spacing: 16) { - Image(systemName: area.systemImage) - .font(.title2) - .foregroundColor(.accentColor) - .frame(width: 32, height: 32) - - VStack(alignment: .leading, spacing: 4) { - Text(area.title) - .font(.headline) - .bold() - .foregroundColor(.primary) - } - - Spacer() - - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .font(.title2) - .foregroundColor(isSelected ? .accentColor : .secondary) - } - .padding() - .background( - RoundedRectangle(cornerRadius: 12) - .fill(isSelected ? Color.accentColor.opacity(0.1) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) - ) - .contentShape(RoundedRectangle(cornerRadius: 12)) - .onTapGesture(perform: self.action) - } -} - -// MARK: - Support Form Area System Images Extension -private extension SupportFormArea { - var systemImage: String { - switch self.id { - case "application": - return "app.badge" - case "jetpack_connection": - return "powerplug" - case "site_management": - return "globe" - case "billing": - return "creditcard" - case "technical": - return "wrench.and.screwdriver" - case "other": - return "questionmark.circle" - default: - return "questionmark.circle" - } - } -} - -// MARK: - Previews -#Preview { - NavigationStack { - Text("Support Form") - } - .sheet(isPresented: .constant(true)) { - NavigationStack { - SupportForm( - supportIdentity: SupportDataProvider.supportUser, - applicationLogs: [SupportDataProvider.applicationLog] - ) - } - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - // Close action for preview - } label: { - Image(systemName: "xmark") - } - } - } - .environmentObject(SupportDataProvider.testing) -} diff --git a/Modules/Sources/Support/Unified Support/Model/UnifiedSupportPickedFile+Import.swift b/Modules/Sources/Support/Unified Support/Model/UnifiedSupportPickedFile+Import.swift index cf6c263b7d98..5d6077d1788a 100644 --- a/Modules/Sources/Support/Unified Support/Model/UnifiedSupportPickedFile+Import.swift +++ b/Modules/Sources/Support/Unified Support/Model/UnifiedSupportPickedFile+Import.swift @@ -4,9 +4,11 @@ import ImageIO import UniformTypeIdentifiers extension UnifiedSupportPickedFile: Transferable { + /// The photo library offers images and videos under their own content types, so both have to be declared. + /// They're stored the same way: `store` works out from the file itself whether re-encoding it is worth it. static var transferRepresentation: some TransferRepresentation { FileRepresentation(importedContentType: .image) { received in - try UnifiedSupportAttachmentStorage.store(received.file, compressingImage: true) + try UnifiedSupportAttachmentStorage.store(received.file) } FileRepresentation(importedContentType: .movie) { received in try UnifiedSupportAttachmentStorage.store(received.file) @@ -27,9 +29,13 @@ enum UnifiedSupportAttachmentStorage { /// Copies a picked file to a directory of its own, so deleting one attachment never touches the others. /// - /// Images are scaled down and re-encoded on the way in: a photo straight from the camera is several times the - /// size of what support needs to see, and a handful of them wouldn't fit in one reply. - static func store(_ file: URL, compressingImage: Bool = false) throws -> UnifiedSupportPickedFile { + /// Still images are scaled down and re-encoded on the way in: a photo straight from the camera is several + /// times the size of what support needs to see, and a handful of them wouldn't fit in one reply. Everything + /// else — videos, documents, archives — is copied as it is. + /// + /// The re-encoding is only kept when it came back smaller. A screenshot is already a fraction of a photo's + /// size and comes out of JPEG larger than it went in, so sending the original is both smaller and sharper. + static func store(_ file: URL) throws -> UnifiedSupportPickedFile { let id = UUID() let directory = URL.cachesDirectory .appendingPathComponent(directoryName) @@ -38,17 +44,61 @@ enum UnifiedSupportAttachmentStorage { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) var destination = directory.appendingPathComponent(file.lastPathComponent) + let compressed = shouldCompress(file) ? compressImage(at: file, in: directory) : nil - // Falling back to the original keeps an image the system can't re-encode attachable. - if compressingImage, let compressed = compressImage(at: file, in: directory) { + // Falling back to the original keeps an image the system can't re-encode attachable, and one the + // re-encoding only made worse. + if let compressed, isWorthKeeping(compressed, insteadOf: file) { destination = compressed } else { + if let compressed { + // Removed before the copy: an image already named `.jpeg` was re-encoded to the very path + // the copy is about to need, and it would otherwise sit in the directory unused. + try? FileManager.default.removeItem(at: compressed) + } try FileManager.default.copyItem(at: file, to: destination) } return UnifiedSupportPickedFile(id: id, url: destination, fileSize: size(of: destination)) } + /// Copies a file picked from outside the photo library, which the app may only read while it holds the + /// file's security scope. + /// + /// The scope is let go of again straight away: the copy is the only thing that needs the original, and iOS + /// hands out a limited number of them. + /// + /// The coordination is handed in so the caller can stop a fetch it no longer wants. + static func store( + securityScoped file: URL, + coordination: UnifiedSupportFileCoordination = UnifiedSupportFileCoordination() + ) throws -> UnifiedSupportPickedFile { + let hasScope = file.startAccessingSecurityScopedResource() + defer { + if hasScope { + file.stopAccessingSecurityScopedResource() + } + } + + // Read through a coordinator: a document picked from iCloud Drive may not be on the device yet, and + // copying it straight away would fail rather than fetch it. + var stored: Result? + var coordinationError: NSError? + coordination.coordinate(readingItemAt: file, error: &coordinationError) { url in + stored = Result { try store(url) } + } + + if let coordinationError { + throw coordinationError + } + + guard let stored else { + throw CocoaError(.fileReadUnknown) + } + + return try stored.get() + } + static func delete(_ file: UnifiedSupportPickedFile) { // The file has a directory of its own, named after its ID. try? FileManager.default.removeItem(at: file.url.deletingLastPathComponent()) @@ -60,6 +110,30 @@ enum UnifiedSupportAttachmentStorage { } } + /// Whether re-encoding the file as a smaller JPEG is worth it. + /// + /// Only still images are: a video costs more to re-encode than it saves, and a document would be destroyed + /// by it. Animated images are left alone too, since re-encoding one keeps nothing but its first frame — + /// which is usually the very thing the user attached it to show. + private static func shouldCompress(_ file: URL) -> Bool { + guard let type = try? file.resourceValues(forKeys: [.contentTypeKey]).contentType else { + return false + } + + return type.conforms(to: .image) && !type.conforms(to: .gif) + } + + /// Whether the re-encoded copy earns its place over the original. + /// + /// `maximumImagePixelSize` only ever scales an image down, so one already smaller than it comes back the + /// same size and only loses quality — and a screenshot of text comes out of JPEG several times the size of + /// its PNG, which would take more of the upload allowance than the original asked for. A copy the system + /// reports no size for isn't taken on trust either. + private static func isWorthKeeping(_ compressed: URL, insteadOf original: URL) -> Bool { + let compressedSize = size(of: compressed) + return compressedSize > 0 && compressedSize < size(of: original) + } + /// Writes a scaled down JPEG copy of an image next to it, and returns where it landed. /// /// Returns `nil` when the image can't be read or written, leaving the caller to send the original. @@ -111,3 +185,27 @@ enum UnifiedSupportAttachmentStorage { return UInt64(size ?? 0) } } + +/// Owns the file coordinator a browsed file is read through, so that leaving the reply form can stop a fetch +/// the coordinator hasn't granted yet. +/// +/// The copy runs on a detached task, which inherits no cancellation of its own, so there is otherwise nothing +/// to stop an iCloud Drive document being downloaded in full for a reply the user has already thrown away. +/// Cancelling only helps before the read is granted — once the file is on the device the coordinator waits for +/// the copy to finish — which is exactly the case worth stopping: the download is what the read waits on. +/// +/// `NSFileCoordinator` is not `Sendable`, but `cancel()` is documented as callable from any thread, which is +/// the only thing done to it from outside the task doing the reading. +final class UnifiedSupportFileCoordination: @unchecked Sendable { + + private let coordinator = NSFileCoordinator() + + func coordinate(readingItemAt url: URL, error: NSErrorPointer, by reader: (URL) -> Void) { + coordinator.coordinate(readingItemAt: url, error: error, byAccessor: reader) + } + + /// Stops a read that hasn't been granted yet, failing it with `NSUserCancelledError`. + func cancel() { + coordinator.cancel() + } +} diff --git a/Modules/Sources/Support/Unified Support/UI/UnifiedSupportAttachmentPicker.swift b/Modules/Sources/Support/Unified Support/UI/UnifiedSupportAttachmentPicker.swift index 62f57846b0ba..82cc37572809 100644 --- a/Modules/Sources/Support/Unified Support/UI/UnifiedSupportAttachmentPicker.swift +++ b/Modules/Sources/Support/Unified Support/UI/UnifiedSupportAttachmentPicker.swift @@ -2,7 +2,10 @@ import PhotosUI import QuickLookThumbnailing import SwiftUI -/// The section of the reply form used to attach images and videos. +/// The section of the reply form used to attach files. +/// +/// Photos and videos come from the photo library, and everything else — documents, archives, exported logs — from +/// the file browser. Both end up in the same list. /// /// Files are taken up to the upload limit, in the order they were picked. The ones that don't fit are listed, so the /// user can make room for them by removing another file. @@ -29,10 +32,19 @@ struct UnifiedSupportAttachmentPicker: View { /// otherwise both take the same item for a new one. @State private var importing: Set = [] + /// How many browsed files are being copied in. + /// + /// The file browser keeps no selection to track these against, so there's nothing to count but the files + /// themselves. + @State private var importingFileCount = 0 + + @State private var isShowingFileBrowser = false + @State private var loadingErrorMessage: String? + /// Whether a file from either source is still being brought in. private var isLoading: Bool { - !importing.isEmpty + !importing.isEmpty || importingFileCount > 0 } var body: some View { @@ -70,27 +82,56 @@ struct UnifiedSupportAttachmentPicker: View { .listRowSeparator(.hidden) } + /// The two places a file can come from, offered side by side. + /// + /// They're separate pickers because the photo library doesn't list documents, and the file browser is a poor + /// way to find a screenshot. Each one says what it opens, so neither needs explaining. private var picker: some View { - PhotosPicker(selection: $selection, matching: .any(of: [.images, .videos])) { - HStack { - if isLoading { - ProgressView() - .tint(Color.accentColor) - } else { - Image(systemName: "paperclip") - } + // Read out here: the labels below are built in a nonisolated context, which can't reach the state. + let isLoadingPhotos = !importing.isEmpty + let isLoadingFiles = importingFileCount > 0 + + return HStack(spacing: 12) { + PhotosPicker(selection: $selection, matching: .any(of: [.images, .videos])) { + UnifiedSupportAttachmentSourceLabel( + title: UnifiedSupportLocalization.attachFromPhotoLibrary, + systemImage: "photo.on.rectangle", + isLoading: isLoadingPhotos + ) + } - Text( - files.isEmpty - ? UnifiedSupportLocalization.addAttachments - : UnifiedSupportLocalization.addMoreAttachments + Button { + isShowingFileBrowser = true + } label: { + UnifiedSupportAttachmentSourceLabel( + title: UnifiedSupportLocalization.attachFromFiles, + systemImage: "folder", + isLoading: isLoadingFiles ) } - .frame(maxWidth: .infinity) - .padding() - .background(Color.accentColor.opacity(0.1)) - .foregroundStyle(Color.accentColor) - .clipShape(RoundedRectangle(cornerRadius: 8)) + // Left to the label, which is already drawn as a button. + .buttonStyle(.plain) + } + // Anything the support team can be sent is worth offering, so the browser isn't narrowed to a list of + // types: a crash report or an exported database is as useful as a screenshot. `data` is every type + // that is a file, which leaves out the one thing there's no sending — a folder. + .fileImporter( + isPresented: $isShowingFileBrowser, + allowedContentTypes: [.data], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): + imports.start { + await load(browsed: urls) + } + case .failure(let error): + // Backing out of the browser isn't a failure worth a message. + guard !error.isUnifiedSupportCancellation else { + return + } + loadingErrorMessage = error.unifiedSupportMessage + } } .onChange(of: selection) { _, newSelection in imports.start { @@ -187,14 +228,14 @@ struct UnifiedSupportAttachmentPicker: View { return } - if importing.isEmpty { + if !isLoading { loadingErrorMessage = nil } importing.formUnion(newItems) isImporting = true defer { importing.subtract(newItems) - isImporting = !importing.isEmpty + isImporting = isLoading } for item in newItems { @@ -219,6 +260,90 @@ struct UnifiedSupportAttachmentPicker: View { } } } + + /// Brings in the files picked from the file browser. + /// + /// Unlike the photo library, the browser remembers no selection, so each pick is only ever an addition and + /// there's nothing to take back off the list here. + private func load(browsed urls: [URL]) async { + guard !urls.isEmpty else { + return + } + + if !isLoading { + loadingErrorMessage = nil + } + importingFileCount += urls.count + isImporting = true + defer { + importingFileCount -= urls.count + isImporting = isLoading + } + + for url in urls { + do { + // Copying a file the size of a video would block the form for as long as it takes. + // + // The coordination is held out here so leaving the form can stop a fetch still waiting on + // iCloud: a detached task inherits no cancellation, and awaiting one isn't a cancellation + // point either, so nothing else would interrupt the download. + let coordination = UnifiedSupportFileCoordination() + let copy = Task.detached(priority: .userInitiated) { + try UnifiedSupportAttachmentStorage.store(securityScoped: url, coordination: coordination) + } + let file = try await withTaskCancellationHandler { + try await copy.value + } onCancel: { + coordination.cancel() + } + + // The form can close while a file is being copied, which leaves the reply it was picked for + // gone, and nowhere to put it. The files queued behind this one have nowhere to go either, so + // they're left uncopied rather than copied and thrown away one by one. + guard !Task.isCancelled else { + UnifiedSupportAttachmentStorage.delete(file) + break + } + files.append(file) + } catch { + // Closing the form cancels the import, which isn't a failure worth a message. + guard !Task.isCancelled, !error.isUnifiedSupportCancellation else { + return + } + loadingErrorMessage = error.unifiedSupportMessage + } + } + } +} + +/// One of the places an attachment can be picked from, drawn as a button. +private struct UnifiedSupportAttachmentSourceLabel: View { + + let title: String + let systemImage: String + + /// Whether this source is still bringing a file in, which replaces its icon with a spinner. + let isLoading: Bool + + var body: some View { + VStack(spacing: 6) { + if isLoading { + ProgressView() + .tint(Color.accentColor) + } else { + Image(systemName: systemImage) + } + + Text(title) + .font(.subheadline) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor.opacity(0.1)) + .foregroundStyle(Color.accentColor) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } } /// The imports running for a reply form. @@ -248,7 +373,7 @@ final class UnifiedSupportAttachmentImports { } } -/// A preview of a picked file, generated by QuickLook so it works for images and videos alike. +/// A preview of a picked file, generated by QuickLook so it works for images, videos and documents alike. private struct UnifiedSupportAttachmentThumbnail: View { let file: UnifiedSupportPickedFile @@ -257,6 +382,10 @@ private struct UnifiedSupportAttachmentThumbnail: View { @State private var image: UIImage? + /// Whether QuickLook has been asked, which is what separates a file still being previewed from one that has + /// no preview to offer. + @State private var didGenerateThumbnail = false + var body: some View { Group { if let image { @@ -264,11 +393,7 @@ private struct UnifiedSupportAttachmentThumbnail: View { .resizable() .aspectRatio(contentMode: .fill) } else { - Color(.systemGray5) - .overlay { - Image(systemName: "doc") - .foregroundStyle(.secondary) - } + placeholder } } .frame(width: Self.size.width, height: Self.size.height) @@ -276,9 +401,30 @@ private struct UnifiedSupportAttachmentThumbnail: View { .accessibilityLabel(file.filename) .task(id: file.id) { image = await generateThumbnail() + didGenerateThumbnail = true } } + /// Names the file once it's clear no preview is coming: the archives and logs QuickLook can't draw would + /// otherwise all be the same anonymous tile. + private var placeholder: some View { + Color(.systemGray5) + .overlay { + VStack(spacing: 4) { + Image(systemName: "doc") + + if didGenerateThumbnail { + Text(file.filename) + .font(.caption2) + .lineLimit(2) + .multilineTextAlignment(.center) + .padding(.horizontal, 4) + } + } + .foregroundStyle(.secondary) + } + } + private func generateThumbnail() async -> UIImage? { let request = QLThumbnailGenerator.Request( fileAt: file.url, diff --git a/Modules/Sources/Support/Unified Support/UnifiedSupportErrorMessage.swift b/Modules/Sources/Support/Unified Support/UnifiedSupportErrorMessage.swift index c191377bc22e..2911c3c04480 100644 --- a/Modules/Sources/Support/Unified Support/UnifiedSupportErrorMessage.swift +++ b/Modules/Sources/Support/Unified Support/UnifiedSupportErrorMessage.swift @@ -20,8 +20,11 @@ extension Error { return UnifiedSupportLocalization.genericErrorMessage } - /// Whether the request was cancelled, which happens when the user leaves the screen while it's loading. + /// Whether the work was cancelled rather than failed, which happens when the user leaves the screen while + /// it's loading or backs out of a system picker. var isUnifiedSupportCancellation: Bool { - self is CancellationError || (self as? URLError)?.code == .cancelled + self is CancellationError + || (self as? URLError)?.code == .cancelled + || (self as? CocoaError)?.code == .userCancelled } } diff --git a/Modules/Sources/Support/Unified Support/UnifiedSupportLocalization.swift b/Modules/Sources/Support/Unified Support/UnifiedSupportLocalization.swift index 812b8d05fab1..c288eb8f0465 100644 --- a/Modules/Sources/Support/Unified Support/UnifiedSupportLocalization.swift +++ b/Modules/Sources/Support/Unified Support/UnifiedSupportLocalization.swift @@ -248,22 +248,22 @@ enum UnifiedSupportLocalization { static let attachments = NSLocalizedString( "com.jetpack.support.unified.reply.attachments", value: "Attachments", - comment: "Title of the part of the reply form where images and videos are attached." + comment: "Title of the part of the reply form where files are attached." ) static let attachmentsDescription = NSLocalizedString( - "com.jetpack.support.unified.reply.attachments.description", - value: "Add images or videos that show the problem.", + "com.jetpack.support.unified.reply.attachments.anyTypeDescription", + value: "Add screenshots, videos, or documents that show the problem.", comment: "Explains what to attach to a reply to the support team." ) - static let addAttachments = NSLocalizedString( - "com.jetpack.support.unified.reply.attachments.add", - value: "Add Images or Videos", - comment: "Button that picks the images and videos to send with a reply." + static let attachFromPhotoLibrary = NSLocalizedString( + "com.jetpack.support.unified.reply.attachments.fromPhotoLibrary", + value: "Photo Library", + comment: "Option that attaches photos and videos from the photo library to a reply." ) - static let addMoreAttachments = NSLocalizedString( - "com.jetpack.support.unified.reply.attachments.addMore", - value: "Add More", - comment: "Button that picks more images and videos to send with a reply." + static let attachFromFiles = NSLocalizedString( + "com.jetpack.support.unified.reply.attachments.fromFiles", + value: "Browse Files", + comment: "Option that attaches documents and other files to a reply by browsing the device." ) static let attachmentsPartial = NSLocalizedString( "com.jetpack.support.unified.reply.attachments.partial", @@ -283,7 +283,7 @@ enum UnifiedSupportLocalization { static let attachmentsFailedTitle = NSLocalizedString( "com.jetpack.support.unified.reply.attachments.failed", value: "Unable to attach the file", - comment: "Title of the message shown when a picked image or video can't be attached to a reply." + comment: "Title of the message shown when a picked file can't be attached to a reply." ) static let removeAttachment = NSLocalizedString( "com.jetpack.support.unified.reply.attachments.remove", diff --git a/Modules/Tests/SupportTests/UnifiedSupport/UnifiedSupportAttachmentStorageTests.swift b/Modules/Tests/SupportTests/UnifiedSupport/UnifiedSupportAttachmentStorageTests.swift index 261acb0b896d..eb00312e3a52 100644 --- a/Modules/Tests/SupportTests/UnifiedSupport/UnifiedSupportAttachmentStorageTests.swift +++ b/Modules/Tests/SupportTests/UnifiedSupport/UnifiedSupportAttachmentStorageTests.swift @@ -10,10 +10,10 @@ struct UnifiedSupportAttachmentStorageTests { /// A photo straight from the camera is several times the size support needs, and a few of them wouldn't fit /// in one reply. @Test func scalesDownAPickedImage() throws { - let original = try makeImage(width: 4032, height: 3024) + let original = try makePhoto(width: 2600, height: 1950) defer { try? FileManager.default.removeItem(at: original) } - let stored = try UnifiedSupportAttachmentStorage.store(original, compressingImage: true) + let stored = try UnifiedSupportAttachmentStorage.store(original) defer { UnifiedSupportAttachmentStorage.delete(stored) } let source = try #require(CGImageSourceCreateWithURL(stored.url as CFURL, nil)) @@ -28,12 +28,52 @@ struct UnifiedSupportAttachmentStorageTests { // What matters for the upload limit: a full size photo comes out as a fraction of the 20 MB allowance. #expect(stored.fileSize > 0) #expect(stored.fileSize < 2_000_000) + #expect(stored.fileSize < size(of: original)) + } + + /// A screenshot is already a fraction of a photo's size, and JPEG gives back more than it takes on flat + /// colour and crisp text — so re-encoding one costs upload allowance and sharpness for nothing. + @Test func keepsAnImageTheReEncodingWouldMakeBigger() throws { + let original = try makeScreenshot(width: 1200, height: 800) + defer { try? FileManager.default.removeItem(at: original) } + + let stored = try UnifiedSupportAttachmentStorage.store(original) + defer { UnifiedSupportAttachmentStorage.delete(stored) } + + #expect(stored.url.pathExtension == "png") + #expect(stored.fileSize == size(of: original)) + + // The rejected JPEG doesn't stay behind taking up room next to the file that was kept. + let directory = stored.url.deletingLastPathComponent() + let siblings = try FileManager.default.contentsOfDirectory(atPath: directory.path) + #expect(siblings == [stored.url.lastPathComponent]) + } + + /// Whichever copy is stored, it is never the larger of the two — across the shapes a reply actually gets, + /// above and below the point at which scaling down starts to help. + @Test func neverStoresAnImageLargerThanTheOriginal() throws { + for (width, height) in [(2600, 1950), (1200, 800), (750, 1334)] { + for original in [ + try makePhoto(width: width, height: height), + try makeScreenshot(width: width, height: height) + ] { + defer { try? FileManager.default.removeItem(at: original) } + + let stored = try UnifiedSupportAttachmentStorage.store(original) + defer { UnifiedSupportAttachmentStorage.delete(stored) } + + #expect(stored.fileSize > 0) + #expect(stored.fileSize <= size(of: original)) + } + } } - /// A video is left alone: re-encoding it here would cost more than it saves. - @Test func keepsANonImageAsItIs() throws { + /// Everything that isn't a still image is left alone: re-encoding a video costs more than it saves, and a + /// document wouldn't survive it. + @Test(arguments: ["mov", "pdf", "txt", "zip", "log"]) + func keepsANonImageAsItIs(_ pathExtension: String) throws { let original = FileManager.default.temporaryDirectory - .appendingPathComponent("\(UUID().uuidString).mov") + .appendingPathComponent("\(UUID().uuidString).\(pathExtension)") try Data(repeating: 0x1, count: 2_048).write(to: original) defer { try? FileManager.default.removeItem(at: original) } @@ -44,13 +84,43 @@ struct UnifiedSupportAttachmentStorageTests { #expect(stored.fileSize == 2_048) } + /// Re-encoding an animated image keeps nothing but its first frame, which is usually the very thing the + /// user attached it to show. + @Test func keepsAnAnimatedImageAsItIs() throws { + let original = try makeAnimatedGif() + defer { try? FileManager.default.removeItem(at: original) } + + let stored = try UnifiedSupportAttachmentStorage.store(original) + defer { UnifiedSupportAttachmentStorage.delete(stored) } + + #expect(stored.url.pathExtension == "gif") + + let source = try #require(CGImageSourceCreateWithURL(stored.url as CFURL, nil)) + #expect(CGImageSourceGetCount(source) == 2) + } + + /// A browsed file is read through its security scope, which a file already inside the sandbox doesn't have + /// and doesn't need. + @Test func storesAFileBrowsedFromOutsideThePhotoLibrary() throws { + let original = FileManager.default.temporaryDirectory + .appendingPathComponent("\(UUID().uuidString).pdf") + try Data(repeating: 0x1, count: 512).write(to: original) + defer { try? FileManager.default.removeItem(at: original) } + + let stored = try UnifiedSupportAttachmentStorage.store(securityScoped: original) + defer { UnifiedSupportAttachmentStorage.delete(stored) } + + #expect(stored.filename == original.lastPathComponent) + #expect(stored.fileSize == 512) + } + /// Each file gets a directory of its own, so removing one attachment leaves the others alone. @Test func deletingOneFileLeavesTheOthers() throws { let original = try makeImage(width: 64, height: 64) defer { try? FileManager.default.removeItem(at: original) } - let first = try UnifiedSupportAttachmentStorage.store(original, compressingImage: true) - let second = try UnifiedSupportAttachmentStorage.store(original, compressingImage: true) + let first = try UnifiedSupportAttachmentStorage.store(original) + let second = try UnifiedSupportAttachmentStorage.store(original) defer { UnifiedSupportAttachmentStorage.delete(second) } UnifiedSupportAttachmentStorage.delete(first) @@ -59,6 +129,121 @@ struct UnifiedSupportAttachmentStorageTests { #expect(FileManager.default.fileExists(atPath: second.url.path)) } + private func makeAnimatedGif() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("\(UUID().uuidString).gif") + let writer = try #require( + CGImageDestinationCreateWithURL(url as CFURL, UTType.gif.identifier as CFString, 2, nil) + ) + + for _ in 0..<2 { + let frame = try makeImage(width: 16, height: 16) + defer { try? FileManager.default.removeItem(at: frame) } + + let source = try #require(CGImageSourceCreateWithURL(frame as CFURL, nil)) + let image = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + CGImageDestinationAddImage(writer, image, nil) + } + + #expect(CGImageDestinationFinalize(writer)) + + return url + } + + private func size(of file: URL) -> UInt64 { + let size = try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize + return UInt64(size ?? 0) + } + + /// Continuous tone, like a camera photo: smooth gradients with fine grain over the top, which is the + /// content JPEG was built for and PNG stores at close to full cost. + private func makePhoto(width: Int, height: Int) throws -> URL { + var pixels = [UInt8](repeating: 0, count: width * height * 4) + var seed: UInt64 = 0x9E37_79B9_7F4A_7C15 + + for y in 0..> 7 + seed ^= seed << 17 + + let grain = Double(seed % 1_000) / 1_000 + let base = 0.45 + 0.35 * sin(Double(x) / Double(width) * 6) * cos(Double(y) / Double(height) * 4) + let offset = (y * width + x) * 4 + + pixels[offset] = channel(base + 0.1 * grain) + pixels[offset + 1] = channel(base * 0.85 + 0.1 * grain) + pixels[offset + 2] = channel(base * 0.7 + 0.1 * grain) + pixels[offset + 3] = .max + } + } + + return try write(pixels: &pixels, width: width, height: height) + } + + /// Flat background with crisp dark blocks for text, like a screenshot of the app: PNG stores it in a + /// fraction of the space JPEG needs for the same edges. + private func makeScreenshot(width: Int, height: Int) throws -> URL { + let context = try #require( + CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) + ) + + context.setFillColor(red: 0.96, green: 0.96, blue: 0.97, alpha: 1) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + + context.setFillColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 1) + for row in stride(from: 20, to: height - 20, by: 26) { + for glyph in stride(from: 16, to: width - 60, by: 17) { + // A gap every so often, so the rows read as words rather than one long bar. + if (glyph + row) % 5 != 0 { + context.fill(CGRect(x: glyph, y: row, width: 11, height: 16)) + } + } + } + + return try write(image: try #require(context.makeImage())) + } + + private func channel(_ value: Double) -> UInt8 { + UInt8(max(0, min(255, value * 255))) + } + + private func write(pixels: inout [UInt8], width: Int, height: Int) throws -> URL { + let context = try #require( + CGContext( + data: &pixels, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) + ) + + return try write(image: try #require(context.makeImage())) + } + + private func write(image: CGImage) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("\(UUID().uuidString).png") + let writer = try #require( + CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) + ) + CGImageDestinationAddImage(writer, image, nil) + #expect(CGImageDestinationFinalize(writer)) + + return url + } + private func makeImage(width: Int, height: Int) throws -> URL { let context = try #require( CGContext( diff --git a/Modules/Tests/SupportTests/UnifiedSupport/UnifiedSupportErrorMessageTests.swift b/Modules/Tests/SupportTests/UnifiedSupport/UnifiedSupportErrorMessageTests.swift index 47d1df16e895..9dedeedd8b32 100644 --- a/Modules/Tests/SupportTests/UnifiedSupport/UnifiedSupportErrorMessageTests.swift +++ b/Modules/Tests/SupportTests/UnifiedSupport/UnifiedSupportErrorMessageTests.swift @@ -33,7 +33,10 @@ struct UnifiedSupportErrorMessageTests { @Test func detectsCancellations() { #expect(CancellationError().isUnifiedSupportCancellation) #expect(URLError(.cancelled).isUnifiedSupportCancellation) + // Backing out of the file browser, which isn't a failure worth a message + #expect(CocoaError(.userCancelled).isUnifiedSupportCancellation) #expect(!MockError.failure.isUnifiedSupportCancellation) #expect(!UnifiedSupportError.offline.isUnifiedSupportCancellation) + #expect(!CocoaError(.fileNoSuchFile).isUnifiedSupportCancellation) } } diff --git a/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift index c44ae2642334..29c8b4372306 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift @@ -61,9 +61,8 @@ struct RootSupportView: View { communitySupportLink if let identity, isUnifiedSupportAvailable { unifiedSupportLink(for: identity) - } - if let identity { - humanSupportLink(for: identity) + } else { + communityForumLink } } @@ -109,16 +108,19 @@ struct RootSupportView: View { } } + /// Offered wherever the unified support flow isn't — a brand with no Happiness Engineer channel, or a user + /// we have no WP.com identity for — so nobody is left with documentation alone. It's the same place the + /// support screen this one replaces sends them. @ViewBuilder - private func humanSupportLink(for identity: SupportUser) -> some View { + private var communityForumLink: some View { NavigationLink { - SupportConversationListView(currentUser: identity) - .environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller + let url = URL(string: "https://wordpress.org/support/forum/mobile/")! + WebKitView(configuration: WebViewControllerConfiguration(url: url)) } label: { SupportAreaRow( - imageName: "envelope.badge", - title: "Ask the Happiness Engineers", - detail: "For your tough questions. We'll reply via email." + imageName: "person.2", + title: Strings.communityForumTitle, + detail: Strings.communityForumDetail ) } } @@ -232,4 +234,14 @@ private enum Strings { value: "Get answers from our support team, anytime", comment: "Description of the row that opens the support conversations." ) + static let communityForumTitle = NSLocalizedString( + "support.root.communityForum.title", + value: "Community Forums", + comment: "Title of the row that opens the WordPress.org support forums." + ) + static let communityForumDetail = NSLocalizedString( + "support.root.communityForum.detail", + value: "Ask a question and get help from our group of volunteers.", + comment: "Description of the row that opens the WordPress.org support forums." + ) } diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index d671970da1f8..ac2569e32d84 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -23,9 +23,6 @@ extension SupportDataProvider { userDataProvider: WpCurrentUserDataProvider( wpcomClient: WordPressDotComClient() ), - supportConversationDataProvider: WpSupportConversationDataProvider( - wpcomClient: WordPressDotComClient() - ), diagnosticsDataProvider: WpDiagnosticsDataProvider(), mediaHost: WordPressDotComClient(), delegate: WpSupportDelegate() @@ -110,34 +107,6 @@ class WpSupportDelegate: NSObject, SupportDelegate { "subaction": "error-replying-to-conversation", "error": error.localizedDescription ]) - case .viewSupportTicketList: - WPAnalytics.track(.supportTickets, properties: [ - "subaction": "view-list" - ]) - case .viewSupportTicket(let id): - WPAnalytics.track(.supportTickets, properties: [ - "subaction": "view-ticket", - "ticket_id": id - ]) - case .createSupportTicket: - WPAnalytics.track(.supportTickets, properties: [ - "subaction": "create-ticket", - ]) - case .failToCreateSupportTicket(let error): - WPAnalytics.track(.supportTickets, properties: [ - "subaction": "error-creating-ticket", - "error": error.localizedDescription - ]) - case .replyToSupportTicket(let id): - WPAnalytics.track(.supportTickets, properties: [ - "subaction": "reply-to-ticket", - "ticket_id": id - ]) - case .failToReplyToSupportTicket(let error): - WPAnalytics.track(.supportTickets, properties: [ - "subaction": "error-replying-to-ticket", - "error": error.localizedDescription - ]) case .viewDiagnostics: WPAnalytics.track(.diagnostics, properties: [ "subaction": "view-list" @@ -271,77 +240,6 @@ actor WpCurrentUserDataProvider: CurrentUserDataProvider { } } -actor WpSupportConversationDataProvider: SupportConversationDataProvider { - - let maximumUploadSize: UInt64 = 30_000_000 // 30MB - - private let wpcomClient: WordPressDotComClient - - init(wpcomClient: WordPressDotComClient) { - self.wpcomClient = wpcomClient - } - - nonisolated func loadSupportConversations() throws -> any CachedAndFetchedResult<[ConversationSummary]> { - return DiskCachedAndFetchedResult(fetchedResult: { - try await self.wpcomClient.api - .supportTickets - .getSupportConversationList() - .data - .map { $0.asConversationSummary() } - }, cacheKey: "support-conversation-list") - } - - nonisolated func loadSupportConversation(id: UInt64) throws -> any CachedAndFetchedResult { - return DiskCachedAndFetchedResult(fetchedResult: { - try await self.wpcomClient.api - .supportTickets - .getSupportConversation(conversationId: id) - .data - .asConversation() - }, cacheKey: "support-conversation-\(id)") - } - - func createSupportConversation( - subject: String, - message: String, - user: SupportUser, - attachments: [URL] - ) async throws -> Conversation { - let params = CreateSupportTicketParams( - subject: subject, - message: message, - application: "jetpack", - attachments: attachments.map { $0.path() } - ) - - return try await self.wpcomClient.api - .supportTickets - .createSupportTicket(params: params) - .data - .asConversation() - } - - func replyToSupportConversation( - id: UInt64, - message: String, - user: SupportUser, - attachments: [URL] - ) async throws -> Conversation { - let params = AddMessageToSupportConversationParams( - message: message, - attachments: attachments.map { $0.path() } - ) - - let conversation = try await self.wpcomClient.api - .supportTickets - .addMessageToSupportConversation(conversationId: id, params: params) - .data - .asConversation() - - return conversation - } -} - actor WpDiagnosticsDataProvider: DiagnosticsDataProvider { func fetchDiskCacheUsage() async throws -> WordPressCoreProtocols.DiskCacheUsage { try await DiskCache.shared.diskUsage() @@ -452,71 +350,6 @@ extension WordPressAPIInternal.BotMessage { } } -extension SupportConversationSummary { - func asConversationSummary() -> Support.ConversationSummary { - Support.ConversationSummary( - id: self.id, - title: self.title, - description: self.description, - status: conversationStatus(from: self.status), - lastMessageSentAt: self.updatedAt - ) - } -} - -extension SupportConversation { - func asConversation() -> Conversation { - Conversation( - id: self.id, - title: self.title, - description: self.description, - lastMessageSentAt: self.updatedAt, - status: conversationStatus(from: self.status), - messages: self.messages.map { $0.asMessage() } - ) - } -} - -extension SupportMessage { - func asMessage() -> Message { - return switch self.author { - case .user(let user): Message( - id: self.id, - content: self.content, - createdAt: self.createdAt, - authorName: user.displayName, - authorIsUser: true, - attachments: self.attachments.compactMap { $0.asAttachment() } - ) - case .supportAgent(let agent): Message( - id: self.id, - content: self.content, - createdAt: self.createdAt, - authorName: agent.name, - authorIsUser: false, - attachments: self.attachments.compactMap { $0.asAttachment() } - ) - } - } -} - -extension SupportAttachment { - func asAttachment() -> Support.Attachment? { - guard let url = URL(string: self.url) else { - return nil - } - - return Support.Attachment( - id: self.id, - filename: self.filename, - contentType: self.contentType, - fileSize: self.size, - url: url, - dimensions: nil - ) - } -} - fileprivate func summarize(_ text: String) async -> String { if #available(iOS 26.0, *) { do { @@ -532,15 +365,3 @@ fileprivate func summarize(_ text: String) async -> String { } } } - -fileprivate func conversationStatus(from string: String) -> Support.ConversationStatus { - switch string { - case "open": .waitingForSupport - case "closed": .closed - case "pending": .waitingForUser - case "solved": .resolved - case "new": .waitingForSupport - case "hold": .waitingForSupport - default: .unknown - } -}