From 84245e494b9a39644f144c75b3daa94f42c4276f Mon Sep 17 00:00:00 2001 From: Adalberto Plaza Date: Thu, 1 Oct 2026 10:20:52 +0200 Subject: [PATCH 1/4] Unified support: remove the Ask the Happiness Engineers entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Get help now covers both chatting with the AI Assistant and replying to a ticket, so the old email-only entry isn't needed. This deletes the screens it reached — the conversation list, the conversation and reply views, the new ticket form, the screenshot picker and the attachment list — along with their models, the SupportConversationDataProvider and SupportFormDataProvider protocols, the ticket tracking actions, and the wordpress-rs mappings behind them. The application log picker and the video player move out of that directory, since the new flow uses both. The AI Assistant chat loses its "Open a Support Ticket" button, which pushed the deleted form. --- .../Support/Extensions/Foundation.swift | 23 - .../Support/InternalDataProvider.swift | 224 --------- Modules/Sources/Support/Localization.swift | 262 +---------- .../Support/Model/SupportConversation.swift | 197 -------- .../Support/Model/SupportFormArea.swift | 33 -- .../Sources/Support/SupportDataProvider.swift | 121 ----- .../ApplicationLogPicker.swift | 0 .../Bot Conversations/ConversationView.swift | 42 -- .../Sources/Support/UI/SingleVideoView.swift | 51 ++ .../AttachmentListView.swift | 273 ----------- .../ScreenshotPicker.swift | 241 ---------- .../SupportConversationListView.swift | 229 --------- .../SupportConversationReplyView.swift | 287 ----------- .../SupportConversationView.swift | 322 ------------- .../Support Conversations/SupportForm.swift | 445 ------------------ .../NewSupport/RootSupportView.swift | 17 - .../NewSupport/SupportDataProvider.swift | 179 ------- 17 files changed, 64 insertions(+), 2882 deletions(-) delete mode 100644 Modules/Sources/Support/Model/SupportConversation.swift delete mode 100644 Modules/Sources/Support/Model/SupportFormArea.swift rename Modules/Sources/Support/UI/{Support Conversations => }/ApplicationLogPicker.swift (100%) create mode 100644 Modules/Sources/Support/UI/SingleVideoView.swift delete mode 100644 Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift delete mode 100644 Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift delete mode 100644 Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift delete mode 100644 Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift delete mode 100644 Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift delete mode 100644 Modules/Sources/Support/UI/Support Conversations/SupportForm.swift 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/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift index c44ae2642334..d5538e6fcc23 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift @@ -62,9 +62,6 @@ struct RootSupportView: View { if let identity, isUnifiedSupportAvailable { unifiedSupportLink(for: identity) } - if let identity { - humanSupportLink(for: identity) - } } Section("Diagnostics") { @@ -109,20 +106,6 @@ struct RootSupportView: View { } } - @ViewBuilder - private func humanSupportLink(for identity: SupportUser) -> some View { - NavigationLink { - SupportConversationListView(currentUser: identity) - .environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller - } label: { - SupportAreaRow( - imageName: "envelope.badge", - title: "Ask the Happiness Engineers", - detail: "For your tough questions. We'll reply via email." - ) - } - } - @ViewBuilder private var applicationLogLink: some View { NavigationLink { 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 - } -} From c67d482c5b21977b8235bac6332a2e08eafd6597 Mon Sep 17 00:00:00 2001 From: Adalberto Plaza Date: Thu, 1 Oct 2026 10:39:15 +0200 Subject: [PATCH 2/4] Unified support: attach documents and other files to a reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reply form could only reach the photo library, so a crash report, an exported log or a PDF had no way in. Photos and videos still come from the photo library; a second button opens the file browser for everything else, and both end up in the same list and the same size budget. A browsed file is read through its security scope and through a file coordinator, so a document that lives in iCloud Drive and isn't on the device yet gets fetched instead of failing. The copy runs off the main actor: one the size of a video would otherwise freeze the form. The browser is opened for `data` rather than `item`, which leaves out the one thing there's no sending — a folder. Whether to re-encode a file is now read off the file itself instead of being passed in by the caller, so a PNG browsed from Files is scaled down like one from the photo library. That also leaves animated images alone, which were being flattened to their first frame — usually the very thing the user attached them to show. Documents QuickLook can't draw now show their name rather than an anonymous icon, and backing out of the file browser no longer reports a failure. --- .../UnifiedSupportPickedFile+Import.swift | 58 +++++- .../UI/UnifiedSupportAttachmentPicker.swift | 189 +++++++++++++++--- .../UnifiedSupportErrorMessage.swift | 7 +- .../UnifiedSupportLocalization.swift | 24 +-- ...UnifiedSupportAttachmentStorageTests.swift | 65 +++++- .../UnifiedSupportErrorMessageTests.swift | 3 + 6 files changed, 294 insertions(+), 52 deletions(-) diff --git a/Modules/Sources/Support/Unified Support/Model/UnifiedSupportPickedFile+Import.swift b/Modules/Sources/Support/Unified Support/Model/UnifiedSupportPickedFile+Import.swift index cf6c263b7d98..cbf38020ae4c 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,10 @@ 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. + static func store(_ file: URL) throws -> UnifiedSupportPickedFile { let id = UUID() let directory = URL.cachesDirectory .appendingPathComponent(directoryName) @@ -40,7 +43,7 @@ enum UnifiedSupportAttachmentStorage { var destination = directory.appendingPathComponent(file.lastPathComponent) // Falling back to the original keeps an image the system can't re-encode attachable. - if compressingImage, let compressed = compressImage(at: file, in: directory) { + if shouldCompress(file), let compressed = compressImage(at: file, in: directory) { destination = compressed } else { try FileManager.default.copyItem(at: file, to: destination) @@ -49,6 +52,38 @@ enum UnifiedSupportAttachmentStorage { 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. + static func store(securityScoped file: URL) 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? + NSFileCoordinator().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 +95,19 @@ 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) + } + /// 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. diff --git a/Modules/Sources/Support/Unified Support/UI/UnifiedSupportAttachmentPicker.swift b/Modules/Sources/Support/Unified Support/UI/UnifiedSupportAttachmentPicker.swift index 62f57846b0ba..60fe932d4d80 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,79 @@ 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. + let file = try await Task.detached(priority: .userInitiated) { + try UnifiedSupportAttachmentStorage.store(securityScoped: url) + }.value + + // The form can close while a file is being copied, which leaves the reply it was picked for + // gone, and nowhere to put it. + guard !Task.isCancelled else { + UnifiedSupportAttachmentStorage.delete(file) + continue + } + 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 +362,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 +371,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 +382,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 +390,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..5e1ffd9e7147 100644 --- a/Modules/Tests/SupportTests/UnifiedSupport/UnifiedSupportAttachmentStorageTests.swift +++ b/Modules/Tests/SupportTests/UnifiedSupport/UnifiedSupportAttachmentStorageTests.swift @@ -13,7 +13,7 @@ struct UnifiedSupportAttachmentStorageTests { let original = try makeImage(width: 4032, height: 3024) 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)) @@ -30,10 +30,12 @@ struct UnifiedSupportAttachmentStorageTests { #expect(stored.fileSize < 2_000_000) } - /// 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 +46,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 +91,27 @@ 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 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) } } From b65335b0be622d37cbaab4a19aea0e3079293843 Mon Sep 17 00:00:00 2001 From: Adalberto Plaza Date: Thu, 1 Oct 2026 17:26:13 +0200 Subject: [PATCH 3/4] Unified support: keep a picked image the re-encoding would only make worse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every still image was re-encoded to a scaled down JPEG on the way in, which is the right trade for a camera photo and the wrong one for a screenshot: 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. The re-encode is now only kept when it came back smaller, so a screenshot is sent as it was taken — both smaller and sharper than the copy that was replacing it. The rejected copy is removed before the original is copied, since an image already named `.jpeg` was re-encoded to the very path the copy is about to need and would otherwise sit in the directory unused. Leaving the reply form now also stops a browsed file still waiting on iCloud. The copy runs on a detached task, which inherits no cancellation of its own, and awaiting one isn't a cancellation point either, so the file coordinator is held outside the task and cancelled from a cancellation handler. That only helps before the read is granted, which is exactly the case worth stopping: an iCloud Drive document being fetched in full for a reply the user has already thrown away. The files queued behind it have nowhere to go either, so they're left uncopied rather than copied and thrown away one by one. --- .../UnifiedSupportPickedFile+Import.swift | 58 +++++++- .../UI/UnifiedSupportAttachmentPicker.swift | 21 ++- ...UnifiedSupportAttachmentStorageTests.swift | 134 +++++++++++++++++- 3 files changed, 203 insertions(+), 10 deletions(-) diff --git a/Modules/Sources/Support/Unified Support/Model/UnifiedSupportPickedFile+Import.swift b/Modules/Sources/Support/Unified Support/Model/UnifiedSupportPickedFile+Import.swift index cbf38020ae4c..5d6077d1788a 100644 --- a/Modules/Sources/Support/Unified Support/Model/UnifiedSupportPickedFile+Import.swift +++ b/Modules/Sources/Support/Unified Support/Model/UnifiedSupportPickedFile+Import.swift @@ -32,6 +32,9 @@ enum UnifiedSupportAttachmentStorage { /// 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 @@ -41,11 +44,18 @@ 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 shouldCompress(file), 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) } @@ -57,7 +67,12 @@ enum UnifiedSupportAttachmentStorage { /// /// 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. - static func store(securityScoped file: URL) throws -> UnifiedSupportPickedFile { + /// + /// 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 { @@ -69,7 +84,7 @@ enum UnifiedSupportAttachmentStorage { // copying it straight away would fail rather than fetch it. var stored: Result? var coordinationError: NSError? - NSFileCoordinator().coordinate(readingItemAt: file, error: &coordinationError) { url in + coordination.coordinate(readingItemAt: file, error: &coordinationError) { url in stored = Result { try store(url) } } @@ -108,6 +123,17 @@ enum UnifiedSupportAttachmentStorage { 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. @@ -159,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 60fe932d4d80..82cc37572809 100644 --- a/Modules/Sources/Support/Unified Support/UI/UnifiedSupportAttachmentPicker.swift +++ b/Modules/Sources/Support/Unified Support/UI/UnifiedSupportAttachmentPicker.swift @@ -283,15 +283,26 @@ struct UnifiedSupportAttachmentPicker: View { for url in urls { do { // Copying a file the size of a video would block the form for as long as it takes. - let file = try await Task.detached(priority: .userInitiated) { - try UnifiedSupportAttachmentStorage.store(securityScoped: url) - }.value + // + // 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. + // 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) - continue + break } files.append(file) } catch { diff --git a/Modules/Tests/SupportTests/UnifiedSupport/UnifiedSupportAttachmentStorageTests.swift b/Modules/Tests/SupportTests/UnifiedSupport/UnifiedSupportAttachmentStorageTests.swift index 5e1ffd9e7147..eb00312e3a52 100644 --- a/Modules/Tests/SupportTests/UnifiedSupport/UnifiedSupportAttachmentStorageTests.swift +++ b/Modules/Tests/SupportTests/UnifiedSupport/UnifiedSupportAttachmentStorageTests.swift @@ -10,7 +10,7 @@ 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) @@ -28,6 +28,44 @@ 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)) + } + } } /// Everything that isn't a still image is left alone: re-encoding a video costs more than it saves, and a @@ -112,6 +150,100 @@ struct UnifiedSupportAttachmentStorageTests { 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( From 1834a68710b7fb767e7f4139b30206f78db01c58 Mon Sep 17 00:00:00 2001 From: Adalberto Plaza Date: Thu, 1 Oct 2026 17:26:19 +0200 Subject: [PATCH 4/4] Unified support: offer the community forums where Get help isn't MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the Ask the Happiness Engineers entry left "How can we help?" with documentation alone in the WordPress app, since Get help is gated to the Jetpack brand. The community forums take its place — the same ones the support screen this one replaces sends people to. The row stands in wherever Get help doesn't appear, which is a user we have no WP.com identity for as much as a brand without the flow. A Jetpack user signed in only to a self-hosted site, or one whose identity failed to load, would otherwise be left with the Help Center and no way to reach a person. Pairing the two as one if/else keeps that structural: exactly one of them always renders. --- .../NewSupport/RootSupportView.swift | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift index d5538e6fcc23..29c8b4372306 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift @@ -61,6 +61,8 @@ struct RootSupportView: View { communitySupportLink if let identity, isUnifiedSupportAvailable { unifiedSupportLink(for: identity) + } else { + communityForumLink } } @@ -106,6 +108,23 @@ 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 var communityForumLink: some View { + NavigationLink { + let url = URL(string: "https://wordpress.org/support/forum/mobile/")! + WebKitView(configuration: WebViewControllerConfiguration(url: url)) + } label: { + SupportAreaRow( + imageName: "person.2", + title: Strings.communityForumTitle, + detail: Strings.communityForumDetail + ) + } + } + @ViewBuilder private var applicationLogLink: some View { NavigationLink { @@ -215,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." + ) }