diff --git a/Modules/Package.resolved b/Modules/Package.resolved index c81073eb357f..9ba4fe400cec 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c33cba27b92a7c9ad551b1485c7e3cdd56cdd5ec529e0f8f1e12fef742736199", + "originHash" : "9f223077b9129eec016dc04a9b2cace12ae0ec8d4328c663514e0b3136cd004e", "pins" : [ { "identity" : "alamofire", @@ -372,8 +372,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Automattic/wordpress-rs", "state" : { - "branch" : "alpha-20250926", - "revision" : "13c6207d6beeeb66c21cd7c627e13817ca5fdcae" + "branch" : "alpha-20251007", + "revision" : "4a9329ee1ee5604fa4d33459524513d7f8507560" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 2646a9b08c3b..9f58157b4afc 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -16,6 +16,7 @@ let package = Package( .library(name: "NotificationServiceExtensionCore", targets: ["NotificationServiceExtensionCore"]), .library(name: "ShareExtensionCore", targets: ["ShareExtensionCore"]), .library(name: "SFHFKeychainUtils", targets: ["SFHFKeychainUtils"]), + .library(name: "Support", targets: ["Support"]), .library(name: "WordPressFlux", targets: ["WordPressFlux"]), .library(name: "WordPressShared", targets: ["WordPressShared"]), .library(name: "WordPressUI", targets: ["WordPressUI"]), @@ -52,8 +53,8 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. - .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250926"), .package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.9.0"), + .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20251007"), .package( url: "https://github.com/Automattic/color-studio", revision: "bf141adc75e2769eb469a3e095bdc93dc30be8de" @@ -132,6 +133,12 @@ let package = Package( name: "SFHFKeychainUtils", cSettings: [.unsafeFlags(["-fno-objc-arc"])] ), + .target( + name: "Support", + dependencies: [ + "AsyncImageKit" + ] + ), .target(name: "TextBundle"), .target( name: "TracksMini", @@ -329,6 +336,7 @@ enum XcodeSupport { "NotificationServiceExtensionCore", "SFHFKeychainUtils", "ShareExtensionCore", + "Support", "WordPressFlux", "WordPressShared", "WordPressLegacy", diff --git a/Modules/Sources/Support/Extensions/Foundation.swift b/Modules/Sources/Support/Extensions/Foundation.swift new file mode 100644 index 000000000000..cac974dd65eb --- /dev/null +++ b/Modules/Sources/Support/Extensions/Foundation.swift @@ -0,0 +1,82 @@ +import Foundation + +extension Date { + var isToday: Bool { + let calendar = Calendar.autoupdatingCurrent + return calendar.isDateInToday(self) + } +} + +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] = [] + + for line in lines { + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + + // Check if line starts with one or more # characters followed by a space + if trimmedLine.hasPrefix("#") { + // Find the first non-# character + let hashCount = trimmedLine.prefix(while: { $0 == "#" }).count + + // Make sure there's at least one # and that it's followed by a space or end of string + if hashCount > 0 && hashCount < trimmedLine.count { + let remainingText = String(trimmedLine.dropFirst(hashCount)) + + // Check if there's a space after the hashes (proper markdown heading format) + if remainingText.hasPrefix(" ") { + let headingText = remainingText.trimmingCharacters(in: .whitespaces) + if !headingText.isEmpty { + // Convert to bold text + convertedLines.append("**\(headingText)**") + continue + } + } + } + } + + // If not a heading, keep the original line + convertedLines.append(line) + } + + return convertedLines.joined(separator: "\n") +} + +func convertMarkdownTextToAttributedString(_ text: String) -> AttributedString { + do { + // The iOS Markdown parser doesn't support headings, so we need to convert those + let modifiedText = convertMarkdownHeadingsToBold(in: text) + return try AttributedString( + markdown: modifiedText, + options: AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + ) + } catch { + // Fallback to plain-text rendering + return AttributedString(text) + } +} diff --git a/Modules/Sources/Support/Extensions/SwiftUI.swift b/Modules/Sources/Support/Extensions/SwiftUI.swift new file mode 100644 index 000000000000..c0917a985c79 --- /dev/null +++ b/Modules/Sources/Support/Extensions/SwiftUI.swift @@ -0,0 +1,5 @@ +import SwiftUI + +extension EdgeInsets { + static let zero = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) +} diff --git a/Modules/Sources/Support/InternalDataProvider.swift b/Modules/Sources/Support/InternalDataProvider.swift new file mode 100644 index 000000000000..9e574d98729e --- /dev/null +++ b/Modules/Sources/Support/InternalDataProvider.swift @@ -0,0 +1,378 @@ +import Foundation + +// This file is all module-internal and provides sample data for UI development + +extension SupportDataProvider { + static let testing = SupportDataProvider( + applicationLogProvider: InternalLogDataProvider(), + botConversationDataProvider: InternalBotConversationDataProvider(), + userDataProvider: InternalUserDataProvider(), + supportConversationDataProvider: InternalSupportConversationDataProvider() + ) + + static let applicationLog = ApplicationLog(path: URL(filePath: #filePath), createdAt: Date(), modifiedAt: Date()) + static let supportUser = SupportUser( + userId: 1234, + username: "demo-user", + email: "test@example.com" + ) + static let botConversation = BotConversation( + id: 1234, + title: "App Crashing on Launch", + mostRecentMessageDate: Date(), + messages: [ + BotMessage( + id: 1001, + text: "Hi, I'm having trouble with the app. It keeps crashing when I try to open it after the latest update. Can you help?", + date: Date().addingTimeInterval(-3600), // 1 hour ago + userWantsToTalkToHuman: false, + isWrittenByUser: true + ), + BotMessage( + id: 1002, + text: "I'm sorry to hear you're experiencing crashes! I'd be happy to help you troubleshoot this issue. Let me ask a few questions to better understand what's happening. What device are you using and what iOS version are you running?", + date: Date().addingTimeInterval(-3540), // 59 minutes ago + userWantsToTalkToHuman: false, + isWrittenByUser: false + ), + BotMessage( + id: 1003, + text: "I'm using an iPhone 14 Pro with iOS 17.5. The app worked fine before the update yesterday.", + date: Date().addingTimeInterval(-3480), // 58 minutes ago + userWantsToTalkToHuman: false, + isWrittenByUser: true + ), + BotMessage( + id: 1004, + text: "Thank you for that information! iOS 17.5 on iPhone 14 Pro should work well with our latest update. Let's try a few troubleshooting steps:\n\n1. First, try force-closing the app and reopening it\n2. If that doesn't work, try restarting your iPhone\n3. As a last resort, you might need to delete and reinstall the app\n\nCan you try step 1 first and let me know if that helps?", + date: Date().addingTimeInterval(-3420), // 57 minutes ago + userWantsToTalkToHuman: false, + isWrittenByUser: false + ), + BotMessage( + id: 1005, + text: "I tried force-closing and restarting my phone, but it's still crashing immediately when I tap the app icon. Should I try reinstalling?", + date: Date().addingTimeInterval(-3300), // 55 minutes ago + userWantsToTalkToHuman: false, + isWrittenByUser: true + ), + BotMessage( + id: 1006, + text: "Yes, let's try reinstalling the app. This will often resolve issues caused by corrupted app data during updates. Here's what to do:\n\n1. Press and hold the app icon until it jiggles\n2. Tap the X to delete it\n3. Go to the App Store and reinstall the app\n4. Sign back into your account\n\nYour data should be preserved if you're signed into your account. Give this a try and let me know how it goes!", + date: Date().addingTimeInterval(-3240), // 54 minutes ago + userWantsToTalkToHuman: false, + isWrittenByUser: false + ), + BotMessage( + id: 1007, + text: "That worked! The app is opening normally now. Thank you so much for your help!", + date: Date().addingTimeInterval(-180), // 3 minutes ago + userWantsToTalkToHuman: false, + isWrittenByUser: true + ), + BotMessage( + id: 1008, + text: "Wonderful! I'm so glad that resolved the issue for you. The reinstall process often fixes problems that occur during app updates. If you run into any other issues, please don't hesitate to reach out. Is there anything else I can help you with today?", + date: Date().addingTimeInterval(-120), // 2 minutes ago + userWantsToTalkToHuman: false, + isWrittenByUser: false + ) + ]) + + static var conversationReferredToHuman: BotConversation { + BotConversation( + id: 5678, + title: "App Crashing on Launch", + mostRecentMessageDate: Date(), + messages: botConversation.messages + [ + BotMessage( + id: 1009, + text: "Can I please talk to a human?", + date: Date().addingTimeInterval(-60), // 1 minute ago + userWantsToTalkToHuman: false, + isWrittenByUser: true + ), + BotMessage( + id: 1010, + text: "I understand you'd prefer to speak with a human support agent. You can easily escalate this to our support team.", + date: Date(), + userWantsToTalkToHuman: true, + isWrittenByUser: false + ) + ]) + } + + 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.", + 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?", + 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.", + 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?", + 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.", + 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?", + 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.", + 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.", + 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), + 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: [] + ), + 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 { + private var logs: [ApplicationLog] = [ + ApplicationLog(path: URL(filePath: #filePath), createdAt: Date(), modifiedAt: Date()), + ApplicationLog(path: URL(filePath: #filePath).deletingLastPathComponent().appendingPathComponent("SupportDataProvider.swift"), createdAt: Date(), modifiedAt: Date()), + ] + + func fetchApplicationLogs() async throws -> [ApplicationLog] { + if Bool.random() { + return self.logs + } else { + throw CocoaError(.fileNoSuchFile) + } + } + + func deleteApplicationLogs(in logs: [ApplicationLog]) async throws { + for log in logs { + guard let index = self.logs.firstIndex(where: { $0.id == log.id }) else { + return + } + + self.logs.remove(at: index) + } + } + + func deleteAllApplicationLogs() async throws { + self.logs = [] + } +} + +actor InternalBotConversationDataProvider: BotConversationDataProvider { + func loadIdentity() async throws -> SupportUser? { + await SupportDataProvider.supportUser + } + + func loadBotConversations() async throws -> [BotConversation] { + [await SupportDataProvider.botConversation] + } + + func loadBotConversation(id: UInt64) async throws -> BotConversation? { + if id == 5678 { + return await SupportDataProvider.conversationReferredToHuman + } + + return await SupportDataProvider.botConversation + } + + func delete(conversationIds: [UInt64]) async throws { + // TODO + } + + func sendMessage(message: String, in conversation: BotConversation?) async throws -> BotConversation { + try await Task.sleep(for: .seconds(8)) + return conversation!.appending(messages: [ + BotMessage( + id: 1100, + text: message, + date: Date(), + userWantsToTalkToHuman: false, + isWrittenByUser: true + ), + BotMessage( + id: 1200, + text: "Thanks – I've noted that down.", + date: Date(), + userWantsToTalkToHuman: false, + isWrittenByUser: false + ) + ]) + } +} + +actor InternalUserDataProvider: CurrentUserDataProvider { + func fetchCurrentSupportUser() async throws -> SupportUser { + await SupportDataProvider.supportUser + } +} + +actor InternalSupportConversationDataProvider: SupportConversationDataProvider { + private var conversations: [UInt64: Conversation] = [:] + + func loadSupportConversations() async throws -> [ConversationSummary] { + try await Task.sleep(for: .seconds(10)) + return await SupportDataProvider.supportConversationSummaries + } + + func loadSupportConversation(id: UInt64) async throws -> Conversation { + let conversation = await SupportDataProvider.supportConversation + self.conversations[id] = conversation + return conversation + } + + func replyToSupportConversation( + id: UInt64, + message: String, + user: SupportUser, + attachments: [URL] + ) async throws -> Conversation { + + let conversation = try await loadSupportConversation(id: id) + + 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(), + messages: [Message( + id: 1234, + content: message, + createdAt: Date(), + authorName: user.username, + authorIsUser: true, + attachments: [] + )] + ) + } +} diff --git a/Modules/Sources/Support/Localization.swift b/Modules/Sources/Support/Localization.swift new file mode 100644 index 000000000000..48c63e21f72d --- /dev/null +++ b/Modules/Sources/Support/Localization.swift @@ -0,0 +1,219 @@ +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" + ) + + // MARK: - ApplicationLogPicker.swift + + static let applicationLogs = NSLocalizedString( + "com.jetpack.support.applicationLogs", + value: "Application Logs", + comment: "Header for application logs section" + ) + static let applicationLogsDescription = NSLocalizedString( + "com.jetpack.support.applicationLogsDescription", + value: "Including logs can help our team investigate issues. Logs may contain recent app activity.", + comment: "Description explaining why including logs is helpful" + ) + static let logFilesToUpload = NSLocalizedString( + "com.jetpack.support.logFilesToUpload", + value: "The following log files will be uploaded:", + comment: "Text indicating which log files will be included in the support request" + ) + static let unableToLoadApplicationLogs = NSLocalizedString( + "com.jetpack.support.unableToLoadApplicationLogs", + value: "Unable to load application logs", + comment: "Error message when application logs cannot be loaded" + ) + static let includeApplicationLogs = NSLocalizedString( + "com.jetpack.support.includeApplicationLogs", + value: "Include application logs", + 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 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" + ) +} diff --git a/Modules/Sources/Support/Model/ApplicationLog.swift b/Modules/Sources/Support/Model/ApplicationLog.swift new file mode 100644 index 000000000000..b81fdb1ca5b8 --- /dev/null +++ b/Modules/Sources/Support/Model/ApplicationLog.swift @@ -0,0 +1,53 @@ +import Foundation +import SwiftUI +import CoreTransferable +import UniformTypeIdentifiers + +public struct ApplicationLog: Identifiable, Sendable { + public let path: URL + public let createdAt: Date + public let modifiedAt: Date + + public var id: String { + path.absoluteString + } + + public init?(filePath: String) throws { + let attributes = try FileManager.default.attributesOfItem(atPath: filePath) + + guard + let creationDate = attributes[.creationDate] as? Date, + let modificationDate = attributes[.modificationDate] as? Date + else { + return nil + } + + self.path = URL(fileURLWithPath: filePath) + self.createdAt = creationDate + self.modifiedAt = modificationDate + } + + public init(path: URL, createdAt: Date, modifiedAt: Date) { + self.path = path + self.createdAt = createdAt + self.modifiedAt = modifiedAt + } +} + +extension ApplicationLog: Transferable { + public static var transferRepresentation: some TransferRepresentation { + FileRepresentation(exportedContentType: .plainText) { (logFile: ApplicationLog) in + SentTransferredFile(logFile.path) + } + ProxyRepresentation(exporting: { (logFile: ApplicationLog) in + try String(contentsOf: logFile.path, encoding: .utf8) + }) + } + + static func exportedContentTypes(visibility: TransferRepresentationVisibility) -> [UTType] { + [ + .plainText, + .fileURL + ] + } +} diff --git a/Modules/Sources/Support/Model/BotConversation.swift b/Modules/Sources/Support/Model/BotConversation.swift new file mode 100644 index 000000000000..fae595dd6b82 --- /dev/null +++ b/Modules/Sources/Support/Model/BotConversation.swift @@ -0,0 +1,28 @@ +import Foundation + +public struct BotConversation: Identifiable, Codable, Sendable, Hashable { + public let id: UInt64 + public let title: String + public let mostRecentMesageDate: Date + public let userWantsHumanSupport: Bool + public let messages: [BotMessage] + + public init(id: UInt64, title: String, mostRecentMessageDate: Date, messages: [BotMessage]) { + self.id = id + self.title = title + self.mostRecentMesageDate = mostRecentMessageDate + self.messages = messages + self.userWantsHumanSupport = messages.contains(where: { $0.userWantsToTalkToHuman }) + } + + public func appending(messages newMessages: [BotMessage]) -> Self { + BotConversation( + id: self.id, + title: self.title, + mostRecentMessageDate: self.mostRecentMesageDate, + messages: (self.messages + newMessages).sorted(by: { lhs, rhs in + lhs.date < rhs.date + }) + ) + } +} diff --git a/Modules/Sources/Support/Model/BotMessage.swift b/Modules/Sources/Support/Model/BotMessage.swift new file mode 100644 index 000000000000..be543c0ed435 --- /dev/null +++ b/Modules/Sources/Support/Model/BotMessage.swift @@ -0,0 +1,31 @@ +import Foundation + +public struct BotMessage: Identifiable, Codable, Sendable, Hashable { + public let id: UInt64 + + public let text: String + public let attributedText: AttributedString + + public let date: Date + + public let userWantsToTalkToHuman: Bool + public let isWrittenByUser: Bool + + public init(id: UInt64, text: String, date: Date, userWantsToTalkToHuman: Bool, isWrittenByUser: Bool) { + self.id = id + self.text = text + self.attributedText = convertMarkdownTextToAttributedString(text) + + self.date = date + self.userWantsToTalkToHuman = userWantsToTalkToHuman + self.isWrittenByUser = isWrittenByUser + } + + var formattedTime: String { + if self.date.isToday { + DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .short) + } else { + DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .short) + } + } +} diff --git a/Modules/Sources/Support/Model/SupportConversation.swift b/Modules/Sources/Support/Model/SupportConversation.swift new file mode 100644 index 000000000000..5a1cb936f607 --- /dev/null +++ b/Modules/Sources/Support/Model/SupportConversation.swift @@ -0,0 +1,100 @@ +import Foundation + +public struct ConversationSummary: Identifiable, Sendable { + public let id: UInt64 + public let title: String + public let description: String + public let attributedDescription: AttributedString + + /// The `description` with any markdown formatting stripped out + public let plainTextDescription: String + public let lastMessageSentAt: Date + + public init( + id: UInt64, + title: String, + description: String, + lastMessageSentAt: Date + ) { + self.id = id + self.title = title + self.description = description + self.attributedDescription = convertMarkdownTextToAttributedString(description) + self.plainTextDescription = NSAttributedString(attributedDescription).string + self.lastMessageSentAt = lastMessageSentAt + } +} + +public struct Conversation: Identifiable, Sendable { + public let id: UInt64 + public let title: String + public let description: String + public let lastMessageSentAt: Date + public let messages: [Message] + + public init( + id: UInt64, + title: String, + description: String, + lastMessageSentAt: Date, + messages: [Message] + ) { + self.id = id + self.title = title + self.description = description + self.lastMessageSentAt = lastMessageSentAt + self.messages = messages + } + + func addingMessage(_ message: Message) -> Conversation { + return Conversation( + id: self.id, + title: self.title, + description: self.description, + lastMessageSentAt: message.createdAt, + messages: self.messages + [message] + ) + } +} + +public struct Message: Identifiable, Sendable { + 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 { + public let id: UInt64 + + public init(id: UInt64) { + self.id = id + } +} diff --git a/Modules/Sources/Support/Model/SupportFormArea.swift b/Modules/Sources/Support/Model/SupportFormArea.swift new file mode 100644 index 000000000000..9eb2f088c052 --- /dev/null +++ b/Modules/Sources/Support/Model/SupportFormArea.swift @@ -0,0 +1,33 @@ +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/Model/SupportUser.swift b/Modules/Sources/Support/Model/SupportUser.swift new file mode 100644 index 000000000000..5889f2fc4b33 --- /dev/null +++ b/Modules/Sources/Support/Model/SupportUser.swift @@ -0,0 +1,28 @@ +import Foundation +import CryptoKit + +public struct SupportUser: Sendable { + public let userId: UInt64 + public let username: String + public let email: String + public let avatarUrl: URL + + public init( + userId: UInt64, + username: String, + email: String, + avatarUrl: URL? = nil + ) { + self.userId = userId + self.username = username + self.email = email + + if let avatarUrl { + self.avatarUrl = avatarUrl + } else { + let data = Data(email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().utf8) + let hash = SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined() + self.avatarUrl = URL(string: "https://gravatar.com/avatar/\(hash)")! + } + } +} diff --git a/Modules/Sources/Support/SupportDataProvider.swift b/Modules/Sources/Support/SupportDataProvider.swift new file mode 100644 index 000000000000..da6b9f0c102e --- /dev/null +++ b/Modules/Sources/Support/SupportDataProvider.swift @@ -0,0 +1,200 @@ +import Foundation + +public enum SupportFormAction { + case viewSupportForm +} + +@MainActor +public final class SupportDataProvider: ObservableObject, Sendable { + + private let applicationLogProvider: ApplicationLogDataProvider + private let botConversationDataProvider: BotConversationDataProvider + private let userDataProvider: CurrentUserDataProvider + private let supportConversationDataProvider: SupportConversationDataProvider + + private weak var supportDelegate: SupportDelegate? + + public init( + applicationLogProvider: ApplicationLogDataProvider, + botConversationDataProvider: BotConversationDataProvider, + userDataProvider: CurrentUserDataProvider, + supportConversationDataProvider: SupportConversationDataProvider, + delegate: SupportDelegate? = nil + ) { + self.applicationLogProvider = applicationLogProvider + self.botConversationDataProvider = botConversationDataProvider + self.userDataProvider = userDataProvider + self.supportConversationDataProvider = supportConversationDataProvider + self.supportDelegate = delegate + } + + // Delegate Methods + public func userDid(_ action: SupportFormAction) { + self.supportDelegate?.userDid(action) + } + + // Support Bots Data Source + public func loadSupportIdentity() async throws -> SupportUser { + try await self.userDataProvider.fetchCurrentSupportUser() + } + + // Bot Conversation Data Source + public func loadConversations() async throws -> [BotConversation] { + try await self.botConversationDataProvider.loadBotConversations() + } + + public func loadConversation(id: UInt64) async throws -> BotConversation? { + try await self.botConversationDataProvider.loadBotConversation(id: id) + } + + public func delete(conversationIds: [UInt64]) async throws { + try await self.botConversationDataProvider.delete(conversationIds: conversationIds) + } + + public func sendMessage(message: String, in conversation: BotConversation?) async throws -> BotConversation { + try await self.botConversationDataProvider.sendMessage(message: message, in: conversation) + } + + // Support Conversations Data Source + public func loadSupportConversations() async throws -> [ConversationSummary] { + try await self.supportConversationDataProvider.loadSupportConversations() + } + + public func loadSupportConversation(id: UInt64) async throws -> Conversation { + try await self.supportConversationDataProvider.loadSupportConversation(id: id) + } + + public func replyToSupportConversation( + id: UInt64, + message: String, + user: SupportUser, + attachments: [URL] + ) async throws -> Conversation { + try await self.supportConversationDataProvider.replyToSupportConversation( + id: id, + message: message, + user: user, + attachments: attachments + ) + } + + public func createSupportConversation( + subject: String, + message: String, + user: SupportUser, + attachments: [URL] + ) async throws -> Conversation { + try await self.supportConversationDataProvider.createSupportConversation( + subject: subject, + message: message, + user: user, + attachments: attachments + ) + } + + // Application Logs + public func fetchApplicationLogs() async throws -> [ApplicationLog] { + try await self.applicationLogProvider.fetchApplicationLogs() + } + + public func readApplicationLog(_ log: ApplicationLog) async throws -> String { + try await self.applicationLogProvider.readApplicationLog(log) + } + + public func deleteApplicationLogs(in list: [ApplicationLog]) async throws { + try await self.applicationLogProvider.deleteApplicationLogs(in: list) + } + + public func deleteAllApplicationLogs() async throws { + try await self.applicationLogProvider.deleteAllApplicationLogs() + } +} + +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) +} + +public protocol CurrentUserDataProvider: Actor { + func fetchCurrentSupportUser() async throws -> SupportUser +} + +public protocol ApplicationLogDataProvider: Actor { + func readApplicationLog(_ log: ApplicationLog) async throws -> String + func fetchApplicationLogs() async throws -> [ApplicationLog] + func deleteApplicationLogs(in logs: [ApplicationLog]) async throws + func deleteAllApplicationLogs() async throws +} + +public extension ApplicationLogDataProvider { + func readApplicationLog(_ log: ApplicationLog) async throws -> String { + try String(contentsOf: log.path, encoding: .utf8) + } + + func readFiles(in directory: URL) async throws -> [ApplicationLog] { + try FileManager.default.contentsOfDirectory(atPath: directory.path).compactMap { filePath in + try ApplicationLog(filePath: filePath) + } + } +} + +public protocol BotConversationDataProvider: Actor { + func loadBotConversations() async throws -> [BotConversation] + func loadBotConversation(id: UInt64) async throws -> BotConversation? + + func sendMessage(message: String, in conversation: BotConversation?) async throws -> BotConversation + func delete(conversationIds: [UInt64]) async throws +} + +public protocol SupportConversationDataProvider: Actor { + func loadSupportConversations() async throws -> [ConversationSummary] + func loadSupportConversation(id: UInt64) async throws -> Conversation + + 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/Application Logs/ActivityLogDetailView.swift b/Modules/Sources/Support/UI/Application Logs/ActivityLogDetailView.swift new file mode 100644 index 000000000000..64ba0d6f7e09 --- /dev/null +++ b/Modules/Sources/Support/UI/Application Logs/ActivityLogDetailView.swift @@ -0,0 +1,131 @@ +import SwiftUI + +/// A view to display the contents of a log file +struct ActivityLogDetailView: View { + + @EnvironmentObject + private var dataProvider: SupportDataProvider + + enum ViewState: Equatable { + case loading + case loaded(String, Bool) + case error(Error) + + static func == (lhs: ActivityLogDetailView.ViewState, rhs: ActivityLogDetailView.ViewState) -> Bool { + return switch (lhs, rhs) { + case (.loading, .loading): true + case (.loaded(let lhscontent, let lhsisSharing), .loaded(let rhscontent, let rhsisSharing)): + lhscontent == rhscontent && lhsisSharing == rhsisSharing + case (.error, .error): true + default: false + } + } + } + + @State + private var state: ViewState = .loading + + @State + private var isSharing: Bool = false + + @State + private var sharingIsDisabled: Bool = true + + let applicationLog: ApplicationLog + + var body: some View { + Group { + switch self.state { + case .loading: + self.loadingView + case .loaded(let content, _): + self.loadedView(content: content) + case .error(let error): + self.errorView(error: error) + } + } + .navigationTitle(applicationLog.path.lastPathComponent) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: self.startSharing) { + Image(systemName: "square.and.arrow.up") + } + .disabled(self.sharingIsDisabled) + } + } + .sheet(isPresented: self.$isSharing, onDismiss: { + guard case .loaded(let content, _) = self.state else { + return + } + + self.state = .loaded(content, false) + }, content: { + ActivityLogSharingView(applicationLog: applicationLog) { + AnyView(erasing: Text("TODO: A new support request with the application log attached")) + } + .presentationDetents([.medium]) + }) + .task(self.loadLogContent) + .refreshable(action: self.loadLogContent) + .onChange(of: state) { oldValue, newValue in + if case .loaded(_, let isSharing) = state { + self.sharingIsDisabled = false + self.isSharing = isSharing + } else { + self.isSharing = false + self.sharingIsDisabled = true + } + } + } + + @ViewBuilder + var loadingView: some View { + ProgressView("Loading log content...").padding() + } + + @ViewBuilder + func loadedView(content: String) -> some View { + ScrollView { + VStack(alignment: .leading) { + TextEditor(text: .constant(content)) + .font(.system(.body, design: .monospaced)) + .fixedSize(horizontal: false, vertical: true) + .scrollDisabled(true) + .padding() + } + } + } + + @ViewBuilder + func errorView(error: Error) -> some View { + ErrorView( + title: "Unable to read log file", + message: error.localizedDescription + ) + } + + private func loadLogContent() async { + do { + let content = try await self.dataProvider.readApplicationLog(applicationLog) + + self.state = .loaded(content, false) + } catch { + self.state = .error(error) + } + } + + private func startSharing() { + guard case .loaded(let content, _) = self.state else { + return + } + + state = .loaded(content, true) + } +} + +#Preview { + NavigationView { + ActivityLogDetailView( + applicationLog: SupportDataProvider.applicationLog ).environmentObject(SupportDataProvider.testing) + } +} diff --git a/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift b/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift new file mode 100644 index 000000000000..da03b29ed4e7 --- /dev/null +++ b/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift @@ -0,0 +1,173 @@ +import SwiftUI + +/// A view that displays a list of application log files in reverse chronological order. +public struct ActivityLogListView: View { + + enum ViewState { + case loading + case loaded([ApplicationLog], DeletionState) + case error(Error) + } + + enum DeletionState { + case none + case confirm + case deleting(Task) + case deletionError(Error) + } + + @EnvironmentObject + private var dataProvider: SupportDataProvider + + @State + var state: ViewState = .loading + + @State + var isConfirmingDeletion: Bool = false + + public init() {} + + public var body: some View { + Group { + switch self.state { + case .loading: + loadingView + case .loaded(let logFiles, let deletionState): + listView(logFiles: logFiles, deletionState: deletionState) + case .error(let error): + ErrorView( + title: "Error loading logs", + message: error.localizedDescription + ) + } + } + .navigationTitle("Activity Logs") + .overlay { + if case .loaded(let array, let deletionState) = self.state { + if array.isEmpty { + ContentUnavailableView { + Label("No Logs Found", systemImage: "doc.text") + } description: { + Text("There are no activity logs available") + } + } + + switch deletionState { + case .none: EmptyView() // Do nothing + case .deleting: ProgressView() + case .confirm: EmptyView() // Do nothing + case .deletionError(let error): ErrorView( + title: "Unable to delete logs", + message: error.localizedDescription + ) + } + } + } + .alert("Are you sure you want to delete all logs?", isPresented: self.$isConfirmingDeletion, actions: { + + Button ("Delete all Logs", role: .destructive) { + self.deleteAllLogFiles() + } + + Button("Cancel", role: .cancel) { + // Alert will be dismissed on its own + } + + }, message: { + Text("You won't be able to get them back.") + }) + .refreshable { + await self.loadLogFiles() + } + .task { + await self.loadLogFiles() + } + } + + @ViewBuilder + func listView(logFiles: [ApplicationLog], deletionState: DeletionState) -> some View { + if !logFiles.isEmpty { + List { + Section { + ForEach(logFiles) { logFile in + NavigationLink( + destination: ActivityLogDetailView(applicationLog: logFile) + .environmentObject(dataProvider) + ) { + SubtitledListViewItem( + title: logFile.createdAt.description, + subtitle: logFile.path.lastPathComponent + ) + } + }.onDelete(perform: self.deleteLogFiles) + } header: { + Text("Log files by created date") + } footer: { + Text("Up to seven days worth of logs are saved.") + } + + Button("Clear All Activity Logs") { + self.isConfirmingDeletion = true + } + } + } + } + + @ViewBuilder + var loadingView: some View { + ProgressView("Loading logs...") + } + + func deleteLogFiles(_ indexSet: IndexSet) { + guard case .loaded(let array, _) = state else { + return + } + + let logsToDelete = indexSet.map { array[$0] } + + let task = Task { + do { + try await self.dataProvider.deleteApplicationLogs(in: logsToDelete) + let refreshedLogList = try await self.dataProvider.fetchApplicationLogs() + self.state = .loaded(refreshedLogList, .none) + } catch { + self.state = .loaded(array, .deletionError(error)) + } + } + + self.state = .loaded(array, .deleting(task)) + } + + func deleteAllLogFiles() { + guard case .loaded(let array, _) = state else { + return + } + + let task = Task { + do { + try await self.dataProvider.deleteAllApplicationLogs() + self.state = .loaded([], .none) + } catch { + self.state = .loaded(array, .deletionError(error)) + } + } + + self.state = .loaded(array, .deleting(task)) + } + + func loadLogFiles() async { + do { + let logs = try await self.dataProvider.fetchApplicationLogs() + self.state = .loaded(logs, .none) + } catch { + self.state = .error(error) + } + } +} + +#Preview { + NavigationView { + ActivityLogListView() .environmentObject(SupportDataProvider.testing) + + } +} diff --git a/Modules/Sources/Support/UI/Application Logs/ActivityLogSharingView.swift b/Modules/Sources/Support/UI/Application Logs/ActivityLogSharingView.swift new file mode 100644 index 000000000000..9ac3beb2ba8e --- /dev/null +++ b/Modules/Sources/Support/UI/Application Logs/ActivityLogSharingView.swift @@ -0,0 +1,152 @@ +import SwiftUI + +enum SharingOption: String, CaseIterable { + case supportTicket = "New Support Ticket" + case exportFile = "Export as File" + + var systemImage: String { + switch self { + case .supportTicket: "questionmark.circle" + case .exportFile: "doc.badge.plus" + } + } + + var description: String { + return switch self { + case .supportTicket: "Send logs directly to support team" + case .exportFile: "Save as a file to share or store" + } + } +} + +struct ActivityLogSharingView: View { + + @Environment(\.dismiss) + private var dismiss + + @State + private var selectedOption: SharingOption = .supportTicket + + let applicationLog: ApplicationLog + + @ViewBuilder + var destination: () -> AnyView + + var body: some View { + NavigationView { + VStack(alignment: .leading, spacing: 24) { + + VStack(spacing: 12) { + ForEach(SharingOption.allCases, id: \.self) { option in + SharingOptionRow( + option: option, + isSelected: selectedOption == option + ) { + selectedOption = option + } + } + } + .padding(.horizontal) + + Spacer() + + VStack(spacing: 12) { + switch selectedOption { + case .exportFile: + ShareLink(item: applicationLog.path) { + Spacer() + Text("Share") + Spacer() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .frame(maxWidth: .infinity) + + case .supportTicket: + NavigationLink(destination: self.destination) { + Spacer() + Text("Share") + Spacer() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal) + } + .padding(.vertical) + .navigationTitle("Share Activity Log") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Cancel") { + dismiss() + } + } + } + } + } +} + +struct SharingOptionRow: View { + let option: SharingOption + let isSelected: Bool + let action: () -> Void + + var body: some View { + HStack(spacing: 16) { + Image(systemName: option.systemImage) + .font(.title2) + .foregroundColor(.accentColor) + .frame(width: 32, height: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(option.rawValue) + .font(.headline) + .foregroundColor(.primary) + + Text(option.description) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + + 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(Rectangle()) + .onTapGesture { + action() + } + } +} + +#Preview { + struct PreviewWrapper: View { + @State private var isPresented = true + + var body: some View { + Color.clear + .sheet(isPresented: $isPresented) { + ActivityLogSharingView(applicationLog: SupportDataProvider.applicationLog) { + AnyView(erasing: Text("Sharing with support!")) + }.presentationDetents([.medium]) + } + } + } + + return PreviewWrapper() +} diff --git a/Modules/Sources/Support/UI/Application Logs/SubtitledListViewItem.swift b/Modules/Sources/Support/UI/Application Logs/SubtitledListViewItem.swift new file mode 100644 index 000000000000..3a92d7082316 --- /dev/null +++ b/Modules/Sources/Support/UI/Application Logs/SubtitledListViewItem.swift @@ -0,0 +1,43 @@ +import SwiftUI + +/// A reusable view component that displays a title and subtitle in a list item format. +public struct SubtitledListViewItem: View { + private let title: String + private let subtitle: String + + /// Initialize a new SubtitledListViewItem + /// - Parameters: + /// - title: The main text to display + /// - subtitle: The secondary text to display below the title + public init(title: String, subtitle: String) { + self.title = title + self.subtitle = subtitle + } + + public var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + + Text(subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(2) + } + .padding(.vertical, 4) + } +} + +#Preview { + List { + SubtitledListViewItem( + title: "Example Title", + subtitle: "This is a longer subtitle that might wrap to a second line depending on the available width" + ) + + SubtitledListViewItem( + title: "Another Item", + subtitle: "Brief description" + ) + } +} diff --git a/Modules/Sources/Support/UI/Bot Conversations/CompositionView.swift b/Modules/Sources/Support/UI/Bot Conversations/CompositionView.swift new file mode 100644 index 000000000000..db677709a268 --- /dev/null +++ b/Modules/Sources/Support/UI/Bot Conversations/CompositionView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct CompositionView: View { + + private let cornerSize: CGSize = CGSize(width: 9, height: 8) + + @State + var text = "" + + @State + var disabled: Bool = false + + @FocusState + private var textFieldIsFocused: Bool + + var action: (String) -> Void + + var body: some View { + HStack(alignment: .center, spacing: 8) { + + if #available(iOS 26.0, *) { + self.textField + .glassEffect() + } else { + self.textField + .cornerRadius(self.cornerSize.width) + .background(Color(.systemGray4).opacity(0.95)) + .clipShape(RoundedRectangle(cornerSize: self.cornerSize)) + } + + Button(action: { + let copy = self.text + self.text = "" + self.textFieldIsFocused = false + self.action(copy) + }) { + Image(systemName: "arrow.up") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + .frame(width: 32, height: 32) + .background(Color.accentColor) + .clipShape(RoundedRectangle(cornerSize: self.cornerSize)) + } + .disabled(self.disabled || self.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + @ViewBuilder + var textField: some View { + TextField("Ask anything...", text: self.$text, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...5) + .padding(.horizontal, 20) + .padding(.vertical, 16) + .focused($textFieldIsFocused) + } +} + +#Preview { + NavigationView { + VStack { + Spacer() + CompositionView { message in + debugPrint(message) + // Do nothing + } + } + } +} + +#Preview { + NavigationView { + ZStack { + List(SupportDataProvider.botConversation.messages) { + Text($0.text) + } + VStack { + Spacer() + CompositionView { message in + // Do nothing + } + } + } + } +} diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationBotIntro.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationBotIntro.swift new file mode 100644 index 000000000000..2f5a666c847a --- /dev/null +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationBotIntro.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct ConversationBotIntro: View { + let currentUser: SupportUser + + var body: some View { + VStack(alignment: .leading, spacing: 24) { + // Blue sparkle/star icon + Image(systemName: "sparkles") + .font(.system(size: 32, weight: .medium)) + .foregroundColor(.blue) + + VStack(alignment: .leading, spacing: 16) { + // Greeting with wave emoji + HStack { + Text("Howdy \(currentUser.username)!") + .font(.title2) + .fontWeight(.semibold) + + Text("👋") + .font(.title2) + } + + // Description text + Text("I'm your personal AI assistant. I can help with any questions about your site or account.") + .font(.body) + .foregroundColor(.secondary) + .lineLimit(nil) + .textSelection(.enabled) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 32) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +#Preview { + ConversationBotIntro(currentUser: SupportDataProvider.supportUser) + .background(Color(.systemBackground)) +} diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift new file mode 100644 index 000000000000..f90d619e794b --- /dev/null +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift @@ -0,0 +1,153 @@ +import SwiftUI + +public struct ConversationListView: View { + + enum ViewState { + case loadingConversations + case loadingConversationsError(Error) + case ready + case deletingConversations(Task) + case deletingConversationsError(Error) + } + + @EnvironmentObject + private var dataProvider: SupportDataProvider + + @State + var conversations: [BotConversation] = [] + + @State + var state: ViewState = .loadingConversations + + @State + var selectedConversations = Set() + + @State + private var deletionTask: Task? = nil + + private let currentUser: SupportUser + + public init(currentUser: SupportUser) { + self.currentUser = currentUser + } + + public var body: some View { + List(selection: $selectedConversations) { + + if case .loadingConversationsError(let error) = self.state { + ErrorView( + title: "Unable to load conversations", + message: error.localizedDescription + ) + } + + ForEach(self.conversations) { conversation in + NavigationLink(destination: ConversationView( + conversation: conversation, + currentUser: currentUser + ).environmentObject(dataProvider)) { + ConversationRow(conversation: conversation) + } + } + .onDelete { indexSet in + self.deleteConversations(at: indexSet) + } + } + .navigationTitle("Conversations") + .toolbar { + ToolbarItem(placement: .primaryAction) { + NavigationLink { + ConversationView( + conversation: nil, + currentUser: currentUser + ).environmentObject(dataProvider) + } + label: { + Image(systemName: "square.and.pencil") + } + } + } + .overlay { + if case .ready = state, self.conversations.isEmpty { + ContentUnavailableView { + Label("No Conversations", systemImage: "message") + } description: { + Text("Start a new conversation using the button above") + } + } + } + .refreshable { + await self.reloadConversations() + } + .task { + await self.reloadConversations() + } + } + + private func reloadConversations() async { + self.state = .loadingConversations + + do { + self.conversations = try await self.dataProvider.loadConversations() + self.state = .ready + } catch { + self.state = .loadingConversationsError(error) + } + } + + private func deleteConversations(at indexSet: IndexSet) { + let conversationIds = indexSet.map { conversations[$0].id } + + self.state = .deletingConversations(Task { + do { + try await self.dataProvider.delete(conversationIds: conversationIds) + self.state = .ready + } + catch { + self.state = .deletingConversationsError(error) + } + }) + } +} + +// MARK: - ConversationRow +struct ConversationRow: View { + let conversation: BotConversation + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(conversation.title) + .font(.headline) + + if let lastMessage = conversation.messages.last { + Text(lastMessage.text) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(1) + + Text(lastMessage.formattedTime) + .font(.caption) + .foregroundColor(.gray) + } else { + Text("No messages") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 4) + } +} + +#Preview { + + NavigationView { + ConversationListView( + currentUser: SupportDataProvider.supportUser + ) + ConversationView( + conversation: SupportDataProvider.botConversation, + currentUser: SupportDataProvider.supportUser + ) + } + .environmentObject(SupportDataProvider.testing) +} diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift new file mode 100644 index 000000000000..a47c96fe8974 --- /dev/null +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift @@ -0,0 +1,319 @@ +import SwiftUI + +public struct ConversationView: View { + + enum ViewState: Equatable { + case idle + case loadingMessages + case loadingMessagesError(Error) + case startingNewConversation + case conversationNotFound + case sendingMessage(String, Task) + case sendingMessageError(Error) + + static func == (lhs: ConversationView.ViewState, rhs: ConversationView.ViewState) -> Bool { + return switch (lhs, rhs) { + case (.idle, .idle): true + case (.loadingMessages, .loadingMessages): true + case (.loadingMessagesError, .loadingMessagesError): true + case (.startingNewConversation, .startingNewConversation): true + case (.conversationNotFound, .conversationNotFound): true + case (.sendingMessage, .sendingMessage): true + case (.sendingMessageError, .sendingMessageError): true + default: false + } + } + } + + @EnvironmentObject + private var dataProvider: SupportDataProvider + + @State + var conversation: BotConversation? + + @State + var currentUser: SupportUser + + @State + var state: ViewState = .idle + + @State + private var showThinkingView = false + + @Namespace + var bottom + + var messages: [BotMessage] { + self.conversation?.messages ?? [] + } + + var isSendingMessage: Bool { + return switch self.state { + case .sendingMessage: true + default: false + } + } + + var title: String { + self.conversation?.title ?? "New Conversation" + } + + private var loadingTask: Task? + + public init(conversation: BotConversation?, currentUser: SupportUser) { + self.conversation = conversation + self.currentUser = currentUser + } + + public var body: some View { + ZStack { + ScrollViewReader { proxy in + List() { + Section { + ConversationBotIntro(currentUser: currentUser) + } + + loadingMessagesError + + Section { + ForEach(self.messages) { message in + MessageView(message: message).id(message.id) + } + + sendingMessageView(proxy: proxy).onChange(of: self.state) { oldValue, newValue in + self.scrollToBottom(using: proxy, animated: true) + } + } + .listRowSeparator(.hidden) + .listRowInsets(.zero) + .listRowBackground(Color.clear) + + sendingMessageError + + switchToHumanSupport + + Text("").padding(.bottom, 0) + .listRowInsets(.zero) + .listRowBackground(Color.clear) + .listRowSpacing(0) + .id(self.bottom) + } + .scrollDismissesKeyboard(.interactively) + .onAppear { + scrollToBottom(using: proxy, animated: false) + } + } + .navigationTitle(self.title) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + VStack { + Spacer() + CompositionView( + disabled: self.isSendingMessage, + action: self.sendMessage + ) + } + } + .task { + if case .idle = self.state { + await self.loadExistingConversation() + } + } + .onChange(of: state) { _, newState in + switch newState { + case .sendingMessage: + // Start a timer to show ThinkingView after 1.5 seconds + Task { + try? await Task.sleep(for: .seconds(1.5)) + await MainActor.run { + // Only show if we're still in sendingMessage state + if case .sendingMessage = self.state { + withAnimation(.easeInOut) { + self.showThinkingView = true + } + } + } + } + default: + // Hide ThinkingView when leaving sendingMessage state + withAnimation(.easeInOut) { + showThinkingView = false + } + } + } + } + + @ViewBuilder + func sendingMessageView(proxy: ScrollViewProxy) -> some View { + if case .sendingMessage(let message, _) = self.state { + MessageView(message: BotMessage( + id: 0, + text: message, + date: Date(), + userWantsToTalkToHuman: false, + isWrittenByUser: true + )) + .onAppear { + withAnimation { + proxy.scrollTo(0, anchor: .bottom) + } + } + .onDisappear { + scrollToBottom(using: proxy, animated: true) + } + + if showThinkingView { + HStack { + Spacer() + ThinkingView() + .transition(.opacity.combined(with: .move(edge: .leading))) + } + .onAppear { + scrollToBottom(using: proxy, animated: true) + } + .onDisappear { + scrollToBottom(using: proxy, animated: true) + } + } + } + } + + @ViewBuilder + var loadingMessagesError: some View { + if case .loadingMessagesError(let error) = self.state { + ErrorView( + title: "Unable to load messages", + message: error.localizedDescription + ) + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .opacity + )) + } + } + + @ViewBuilder + var sendingMessageError: some View { + if case .sendingMessageError(let error) = self.state { + ErrorView( + title: "Unable to send message", + message: error.localizedDescription + ) + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .opacity + )) + } + } + + @ViewBuilder + var switchToHumanSupport: some View { + if let conversation, conversation.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("Open a Support Ticket") + .font(.headline) + .padding(.vertical) + .frame(maxWidth: .infinity) + } + } + + private func scrollToBottom(using proxy: ScrollViewProxy, animated: Bool) { + if animated { + withAnimation { + proxy.scrollTo(bottom, anchor: .bottom) + } + } else { + proxy.scrollTo(bottom, anchor: .bottom) + } + } + + private func loadExistingConversation() async { + self.state = .loadingMessages + + do { + guard let conversationId = self.conversation?.id else { + await MainActor.run { + self.state = .startingNewConversation + } + return + } + + guard let conversation = try await self.dataProvider.loadConversation(id: conversationId) else { + await MainActor.run { + self.state = .conversationNotFound + } + return + } + + await MainActor.run { + self.conversation = conversation + self.state = .idle + } + + } catch { + await MainActor.run { + self.state = .loadingMessagesError(error) + } + } + } + + private func sendMessage(_ message: String) { + let sendTask = Task { + do { + let conversation = try await self.dataProvider.sendMessage( + message: message, + in: self.conversation + ) + + await MainActor.run { + self.conversation = conversation + self.state = .idle + } + } catch { + debugPrint("🚩 Error: \(error.localizedDescription)") + self.state = .sendingMessageError(error) + } + } + + self.state = .sendingMessage(message, sendTask) + } +} + +#Preview("Default chat") { + NavigationView { + ConversationView( + conversation: SupportDataProvider.botConversation, + currentUser: SupportDataProvider.supportUser + ).environmentObject(SupportDataProvider.testing) + } +} + +#Preview("User wants to chat with a human") { + NavigationView { + ConversationView( + conversation: SupportDataProvider.conversationReferredToHuman, + currentUser: SupportDataProvider.supportUser + ).environmentObject(SupportDataProvider.testing) + } +} diff --git a/Modules/Sources/Support/UI/Bot Conversations/MessageView.swift b/Modules/Sources/Support/UI/Bot Conversations/MessageView.swift new file mode 100644 index 000000000000..1465a3ca6cf4 --- /dev/null +++ b/Modules/Sources/Support/UI/Bot Conversations/MessageView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct MessageView: View { + let message: BotMessage + + var body: some View { + HStack(alignment: .bottom) { + if message.isWrittenByUser { + Spacer() + } + + VStack(alignment: message.isWrittenByUser ? .trailing : .leading, spacing: 4) { + Text(message.attributedText) + .padding(12) + .background(message.isWrittenByUser ? Color.blue : Color(.systemGray5)) + .foregroundColor(message.isWrittenByUser ? .white : .primary) + .cornerRadius(16) + + Text(message.formattedTime) + .font(.caption2) + .foregroundColor(.gray) + .padding(.horizontal, 8) + } + .padding(.vertical, 4) + + if !message.isWrittenByUser { + Spacer() + } + } + .padding(.horizontal) + } +} + +#Preview { + MessageView(message: BotMessage(id: 1234, text: "Hello World", date: Date().addingTimeInterval(-423432), userWantsToTalkToHuman: false, isWrittenByUser: true)) + MessageView(message: BotMessage(id: 5678, text: "Hello back, how are you doing?", date: Date(), userWantsToTalkToHuman: false, isWrittenByUser: false)) +} diff --git a/Modules/Sources/Support/UI/Bot Conversations/ThinkingView.swift b/Modules/Sources/Support/UI/Bot Conversations/ThinkingView.swift new file mode 100644 index 000000000000..ef911824ccdd --- /dev/null +++ b/Modules/Sources/Support/UI/Bot Conversations/ThinkingView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct ThinkingView: View { + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "sparkles") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.gray) + + // Thinking text with shimmer effect + Text("Thinking...") + .font(.system(size: 16, weight: .medium)) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(.systemGray6)) + ) + .shimmer() + } +} + +#Preview { + ThinkingView().padding() +} diff --git a/Modules/Sources/Support/UI/ErrorView.swift b/Modules/Sources/Support/UI/ErrorView.swift new file mode 100644 index 000000000000..9bc58d8bf5c3 --- /dev/null +++ b/Modules/Sources/Support/UI/ErrorView.swift @@ -0,0 +1,85 @@ +import SwiftUI + +struct ErrorView: View { + let title: String + let message: String + let systemImage: String + let retryAction: (() -> Void)? + + init( + title: String = "Something went wrong", + message: String = "Please try again later", + systemImage: String = "exclamationmark.triangle.fill", + retryAction: (() -> Void)? = nil + ) { + self.title = title + self.message = message + self.systemImage = systemImage + self.retryAction = retryAction + } + + var body: some View { + VStack(spacing: 16) { + // Error icon + Image(systemName: systemImage) + .font(.system(size: 48, weight: .medium)) + .foregroundStyle(.red.gradient) + + VStack(spacing: 8) { + // Error title + Text(title) + .font(.headline) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + + // Error message + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(4) + } + + // Retry button (if action provided) + if let retryAction { + Button("Try Again") { + retryAction() + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + } + } + .padding(.vertical, 24) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(.regularMaterial) + .stroke(.quaternary, lineWidth: 0.5) + ) + } +} + +#Preview { + VStack(spacing: 20) { + // Basic error view + ErrorView() + + // Network error with retry + ErrorView( + title: "Network Error", + message: "Unable to connect to the server. Check your internet connection and try again.", + systemImage: "wifi.exclamationmark", + retryAction: { + print("Retry tapped") + } + ) + + // Custom error + ErrorView( + title: "No Data Available", + message: "There's nothing to show right now.", + systemImage: "tray" + ) + } + .background(.gray.opacity(0.1)) +} diff --git a/Modules/Sources/Support/UI/FX/ShimmerEffect.swift b/Modules/Sources/Support/UI/FX/ShimmerEffect.swift new file mode 100644 index 000000000000..78f5a44d7216 --- /dev/null +++ b/Modules/Sources/Support/UI/FX/ShimmerEffect.swift @@ -0,0 +1,73 @@ +import SwiftUI + +public struct ShimmerEffect: ViewModifier { + + @State + private var isAnimating: Bool = false + + let duration: TimeInterval + let delay: TimeInterval + + init(duration: TimeInterval = 1.5, delay: TimeInterval = 0.25) { + self.duration = duration + self.delay = delay + + } + + public func body(content: Content) -> some View { + content + .mask { + LinearGradient( + colors: [ + Color.gray.opacity(0.4), + Color.gray, + Color.gray.opacity(0.1)], + startPoint: (isAnimating ? UnitPoint(x: -0.3, y: -0.3) : UnitPoint(x: 1, y: 1)), + endPoint: (isAnimating ? UnitPoint(x: 0, y: 0) : UnitPoint(x: 1.3, y: 1.3)) + ) + } + .frame(maxWidth: .infinity, alignment: .init(horizontal: .center, vertical: .center)) + .animation(.easeInOut(duration: self.duration).delay(self.delay).repeatForever(autoreverses: true), value: isAnimating) + .onAppear() { + isAnimating = true + } + } +} + +extension View { + func shimmer() -> some View { + modifier(ShimmerEffect()) + } +} + +#Preview { + VStack(spacing: 30) { + Text("Thinking...") + .font(.largeTitle) + .fontWeight(.bold) + .shimmer() + .foregroundStyle(.gray) + + Text("Thinking...") + .font(.title2) + .fontWeight(.semibold) + .shimmer() + .foregroundStyle(.orange) + + Text("Thinking...") + .font(.body) + .fontWeight(.bold) + .shimmer() + .foregroundStyle(.blue) + + Text("Thinking...") + .font(.body) + .shimmer() + .foregroundStyle(.pink) + + Text("Thinking...") + .font(.caption) + .shimmer() + .foregroundStyle(.green) + } +} diff --git a/Modules/Sources/Support/UI/ProfileView.swift b/Modules/Sources/Support/UI/ProfileView.swift new file mode 100644 index 000000000000..17f0626d8f47 --- /dev/null +++ b/Modules/Sources/Support/UI/ProfileView.swift @@ -0,0 +1,126 @@ +import SwiftUI +import AsyncImageKit + +/// A view component that displays a user profile banner with avatar, name, and email address. +/// Tapping on the banner allows the user to modify their details. +public struct ProfileView: View { + + public typealias Callback = () -> Void + + private let name: String + private let email: String + private let avatarImage: Image? + private let avatarImageUrl: URL? + private let onTap: Callback? + + /// Initialize a new ProfileView + /// - Parameters: + /// - name: The user's display name + /// - email: The user's email address + /// - avatarImage: Optional image to display as the user's avatar + /// - onTap: Action to perform when the profile banner is tapped + public init( + name: String, + email: String, + avatarImage: Image? = nil, + onTap: Callback? = nil + ) { + self.name = name + self.email = email + self.avatarImage = avatarImage + self.avatarImageUrl = nil + self.onTap = onTap + } + + public init(user: SupportUser, onTap: Callback? = nil) { + self.name = user.username + self.email = user.email + self.avatarImage = nil + self.avatarImageUrl = user.avatarUrl + self.onTap = onTap + } + + public var body: some View { + Button(action: self.didTapProfile) { + VStack(alignment: .leading) { + HStack(spacing: 16) { + if let avatarImage { + avatarImage + .resizable() + .scaledToFill() + .frame(width: 60, height: 60) + .clipShape(Circle()) + } else if let avatarImageUrl { + CachedAsyncImage(url: avatarImageUrl) { image in + image + .resizable() + .scaledToFill() + .frame(width: 60, height: 60) + .clipShape(Circle()) + } placeholder: { + ProgressView() + } + } else { + ZStack { + Circle() + .fill(Color.secondary.opacity(0.2)) + .frame(width: 60, height: 60) + + Image(systemName: "person.fill") + .font(.system(size: 30)) + .foregroundColor(.secondary) + } + } + + // User details + VStack(alignment: .leading, spacing: 4) { + Text(name) + .font(.headline) + .foregroundColor(.primary) + .lineLimit(1) + + Text(email) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } + .buttonStyle(PlainButtonStyle()) + } + + private func didTapProfile() { + self.onTap?() + } +} + +#Preview("List") { + List { + Section { + ProfileView( + name: "Jane Smith", + email: "jane.smith@example-corporation.com", + onTap: {} + ) + } + } +} + +#Preview("Standalone") { + ProfileView( + name: "John Doe", + email: "john.doe@example.com", + onTap: {} + ) + .padding() + .background(Color.gray.opacity(0.1)) +} + +#Preview("Specific Image") { + ProfileView(user: SupportUser( + userId: 1234, + username: "Alice Roe", + email: "alice.roe@example.com", + avatarUrl: URL(string: "https://docs.gravatar.com/wp-content/uploads/2025/02/avatar-default-20250210-256.png")!)) +} diff --git a/Modules/Sources/Support/UI/Support Conversations/ApplicationLogPicker.swift b/Modules/Sources/Support/UI/Support Conversations/ApplicationLogPicker.swift new file mode 100644 index 000000000000..83e6bb3234a3 --- /dev/null +++ b/Modules/Sources/Support/UI/Support Conversations/ApplicationLogPicker.swift @@ -0,0 +1,138 @@ +import SwiftUI + +struct ApplicationLogPicker: View { + + enum ViewState { + case loading + case loaded([ApplicationLog]) + case error(Error) + } + + @EnvironmentObject + private var dataProvider: SupportDataProvider + + @Binding + var includeApplicationLogs: Bool + + @State + var state: ViewState = .loading + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 12) { + Toggle(isOn: $includeApplicationLogs.animation(.easeInOut(duration: 0.3))) { + Text(Localization.includeApplicationLogs) + .font(.body) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.3)) { + self.includeApplicationLogs.toggle() + } + } + } + + Text(Localization.applicationLogsDescription) + .font(.caption) + .foregroundColor(.secondary) + }.padding(4) + } header: { + HStack { + Text(Localization.applicationLogs) + Text(Localization.optional) + .font(.caption) + .foregroundColor(.secondary) + } + } footer: { + if includeApplicationLogs { + switch self.state { + case .loading: ProgressView() + case .loaded(let logs): + applicationLogList(logs) + case .error(let error): + ErrorView( + title: Localization.unableToLoadApplicationLogs, + message: error.localizedDescription + ) + } + } + }.task { + await loadApplicationLogs() + } + } + + private func loadApplicationLogs() async { + do { + let logs = try await dataProvider.fetchApplicationLogs() + self.state = .loaded(logs) + } catch { + self.state = .error(error) + } + } + + @ViewBuilder + func applicationLogList(_ applicationLogs: [ApplicationLog]) -> some View { + // Show a brief list of logs + VStack(alignment: .leading, spacing: 8) { + Text(Localization.logFilesToUpload) + ForEach(applicationLogs, id: \.path) { log in + ApplicationLogRow(log: log) + } + } + .padding(.vertical) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } +} + +struct ApplicationLogRow: View { + let log: ApplicationLog + + var body: some View { + HStack(alignment: .top, spacing: 16) { + Image(systemName: "doc.text") + .font(.title2) + .foregroundColor(.accentColor) + .frame(width: 32, height: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(log.path.lastPathComponent) + .font(.body) + .foregroundColor(.primary) + .lineLimit(1) + + Text(formatDate(log.modifiedAt)) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding() + .background(Color.clear) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter.string(from: date) + } +} + +#Preview { + struct Preview: View { + @State var includeApplicationLogs: Bool = false + var body: some View { + Form { + ApplicationLogPicker( + includeApplicationLogs: $includeApplicationLogs + ) + }.environmentObject(SupportDataProvider.testing) + + } + } + + return Preview() +} diff --git a/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift b/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift new file mode 100644 index 000000000000..b52843ea6922 --- /dev/null +++ b/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift @@ -0,0 +1,172 @@ +import SwiftUI +import PhotosUI + +struct ScreenshotPicker: View { + + private let maxScreenshots = 5 + + @State + private var selectedPhotos: [PhotosPickerItem] = [] + + @State + private var attachedImages: [UIImage] = [] + + @State + private var error: Error? + + @Binding + var attachedImageUrls: [URL] + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 12) { + Text(Localization.screenshotsDescription) + .font(.caption) + .foregroundColor(.secondary) + + // Screenshots display + if !attachedImages.isEmpty { + 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) + } + } + + if let error { + ErrorView( + title: "Unable to load screenshot", + message: error.localizedDescription + ).frame(maxWidth: .infinity) + } + + // Add screenshots button + PhotosPicker( + selection: $selectedPhotos, + maxSelectionCount: maxScreenshots, + matching: .images + ) { + HStack { + Image(systemName: "camera.fill") + Text(attachedImages.isEmpty ? Localization.addScreenshots : Localization.addMoreScreenshots) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor.opacity(0.1)) + .foregroundColor(Color.accentColor) + .cornerRadius(8) + } + .onChange(of: selectedPhotos) { _, newItems in + Task { + await loadSelectedPhotos(newItems) + } + } + } + } header: { + HStack { + Text(Localization.screenshots) + Text(Localization.optional) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + /// Loads selected photos from PhotosPicker + func loadSelectedPhotos(_ items: [PhotosPickerItem]) async { + var newImages: [UIImage] = [] + var newUrls: [URL] = [] + + do { + for item in items { + if let data = try await item.loadTransferable(type: Data.self) { + if let image = UIImage(data: data) { + newImages.append(image) + } + } + + if let file = try await item.loadTransferable(type: ScreenshotFile.self) { + newUrls.append(file.url) + } + } + + await MainActor.run { + attachedImages = newImages + attachedImageUrls = newUrls + } + } catch { + await MainActor.run { + withAnimation { + self.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) + } + .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 new file mode 100644 index 000000000000..06f0799d270b --- /dev/null +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift @@ -0,0 +1,139 @@ +import SwiftUI + +public struct SupportConversationListView: View { + + enum ViewState { + case loading + case loaded([ConversationSummary]) + case error(Error) + } + + @EnvironmentObject + private var dataProvider: SupportDataProvider + + @State + private var state: ViewState = .loading + + @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 .loading: + ProgressView(Localization.loadingConversations) + case .loaded(let conversations): self.conversationsList(conversations) + case .error(let error): + ErrorView( + title: Localization.errorLoadingSupportConversations, + message: error.localizedDescription + ) + } + } + .navigationTitle(Localization.supportConversations) + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + self.isComposingNewMessage = true + } + label: { + Image(systemName: "square.and.pencil") + } + } + } + .sheet(isPresented: self.$isComposingNewMessage, content: { + NavigationView { + SupportForm(supportIdentity: self.currentUser) + }.environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller + }) + .task(self.loadConversations) + .refreshable(action: self.loadConversations) + } + + @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) + } + + private func loadConversations() async { + do { + let conversations = try await dataProvider.loadSupportConversations() + + await MainActor.run { + self.state = .loaded(conversations) + } + } catch { + await MainActor.run { + self.state = .error(error) + } + } + } +} + +// MARK: - Email Row View +struct EmailRowView: View { + let conversation: ConversationSummary + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(conversation.title) + .font(.headline) + .foregroundColor(.primary) + .lineLimit(1) + + Spacer() + + HStack(spacing: 4) { + Text(formatTimestamp(conversation.lastMessageSentAt)) + .font(.caption) + .foregroundColor(.secondary) + } + }.padding(.bottom, 4) + + Text(conversation.plainTextDescription) + .font(.body) + .foregroundColor(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .padding() + .background(Color.clear) + } + + private func formatTimestamp(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } +} + +#Preview { + NavigationView { + 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 new file mode 100644 index 000000000000..a5e93d266a9b --- /dev/null +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift @@ -0,0 +1,225 @@ +import SwiftUI +import PhotosUI + +public struct SupportConversationReplyView: View { + + enum ViewState: Equatable { + case editing + case sending(Task) + case sent(Task) + case error(Error) + + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.editing, .editing): return true + case (.sending, .sending): return true + case (.sent, .sent): return true + case (.error(let lhsError), .error(let rhsError)): return lhsError.localizedDescription == rhsError.localizedDescription + default: 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 + + @FocusState + private var isTextFieldFocused: Bool + + @State + private var selectedPhotos: [URL] = [] + + @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 + } + + 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 + ) + + ApplicationLogPicker( + includeApplicationLogs: self.$includeApplicationLogs + ) + } + } + .navigationTitle(Localization.reply) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(Localization.cancel) { + dismiss() + } + .disabled({ + if case .sending = state { + return true + } + return false + }()) + } + + ToolbarItem(placement: .confirmationAction) { + Button { + self.sendReply() + } label: { + if case .sending = state { + HStack { + ProgressView() + .scaleEffect(0.8) + Text(Localization.sending) + } + } else { + Text(Localization.send) + } + } + .disabled(!canSendMessage) + } + } + .overlay { + switch self.state { + case .error(let error): + ErrorView( + title: Localization.unableToSendMessage, + message: error.localizedDescription + ) + case .sent: + ContentUnavailableView( + Localization.messageSent, + systemImage: "checkmark.circle", + description: nil + ).onTapGesture { + self.dismiss() + } + default: EmptyView() + } + } + .onAppear { + isTextFieldFocused = true + } + } + + @ViewBuilder + var textEditor: some View { + if #available(iOS 26.0, *) { + 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, *) { + return self.richText.toHtml() + } else { + return self.plainText.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + private func sendReply() { + guard !textIsEmpty else { return } + + let 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)) + + await MainActor.run { + dismiss() + } + }) + } + } catch { + state = .error(error) + + // Reset to editing state after showing error for a moment + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + if case .error = state { + state = .editing + } + } + } + + withAnimation { + state = .sending(task) + } + } + + 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 { + NavigationStack { + Text("Hello World") + }.sheet(isPresented: .constant(true)) { + 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 new file mode 100644 index 000000000000..1929abcb898e --- /dev/null +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift @@ -0,0 +1,268 @@ +import SwiftUI + +public struct SupportConversationView: View { + + enum ViewState { + case loading + case loaded(Conversation) + case error(Error) + } + + @EnvironmentObject + private var dataProvider: SupportDataProvider + + @State + private var state: ViewState + + @State + private var isReplying: Bool = false + + private let conversationSummary: ConversationSummary + + private let currentUser: SupportUser + + private var canReply: Bool { + if case .loaded = state { + return true + } + return false + } + + public init( + conversation: ConversationSummary, + currentUser: SupportUser + ) { + self.state = .loading + self.currentUser = currentUser + self.conversationSummary = conversation + } + + public var body: some View { + VStack(spacing: 0) { + switch self.state { + case .loading: + ProgressView(Localization.loadingMessages) + case .loaded(let conversation): self.conversationView(conversation) + case .error(let error): + ErrorView( + title: Localization.unableToDisplayConversation, + message: error.localizedDescription + ) + } + } + .navigationTitle(self.conversationSummary.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button { + self.isReplying = true + } label: { + Image(systemName: "arrowshape.turn.up.left") + } + .disabled(!canReply) + } + } + .sheet(isPresented: $isReplying) { + if case .loaded(let conversation) = state { + NavigationView { + SupportConversationReplyView( + conversation: conversation, + currentUser: currentUser, + conversationDidUpdate: { conversation in + withAnimation { + self.state = .loaded(conversation) + } + } + ) + } + .environmentObject(dataProvider) + } + } + .task(self.loadConversation) + .refreshable(action: self.loadConversation) + } + + @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 + ) + } + 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) + } + } + .background(Color(UIColor.systemGroupedBackground)) + .onAppear { + scrollToBottom(proxy: proxy) + } + .onChange(of: conversation.messages.count) { _, _ in + scrollToBottom(proxy: proxy) + } + } + } + + @ViewBuilder + private func conversationHeader(_ conversation: Conversation) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Label( + messageCountString(conversation), + systemImage: "bubble.left.and.bubble.right" + ) + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + HStack(spacing: 0) { + Text(lastUpdatedString) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + .padding() + } + + private func scrollToBottom(proxy: ScrollViewProxy) { + guard case .loaded(let conversation) = state else { + return + } + + if let lastMessage = conversation.messages.last { + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(lastMessage.id, 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()) + } + + private func loadConversation() async { + do { + let conversationId = self.conversationSummary.id + let conversation = try await self.dataProvider.loadSupportConversation(id: conversationId) + self.state = .loaded(conversation) + } catch { + self.state = .error(error) + } + } +} + +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) + } +} + +struct AttachmentListView: View { + let attachments: [Attachment] + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(attachments, id: \.id) { attachment in + HStack { + Image(systemName: "paperclip") + .font(.caption) + .foregroundColor(.secondary) + + Text(String(format: Localization.attachment, attachment.id)) + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + Button(Localization.view) { + // Handle attachment viewing + } + .font(.caption) + } + .padding(.vertical, 2) + } + } + .padding(.top, 4) + } +} + +#Preview { + NavigationView { + SupportConversationView( + conversation: SupportDataProvider.supportConversationSummaries.first!, + currentUser: SupportUser( + userId: 1, + username: "john_doe", + email: "john@example.com" + ) + ) + } + .environmentObject(SupportDataProvider.testing) +} diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift new file mode 100644 index 000000000000..96518f0da20d --- /dev/null +++ b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift @@ -0,0 +1,394 @@ +import Foundation +import SwiftUI +import PhotosUI + +public struct SupportForm: View { + + @EnvironmentObject + private var dataProvider: SupportDataProvider + + /// 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] = [] + + /// UI State + @State private var showLoadingIndicator = false + @State private var shouldShowErrorAlert = false + @State private var shouldShowSuccessAlert = false + @State private var errorMessage = "" + + /// Callback for when form is dismissed + public var onDismiss: (() -> Void)? + + 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 + || subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || problemDescriptionIsEmpty + } + + public init( + onDismiss: (() -> Void)? = nil, + supportIdentity: SupportUser, + applicationLogs: [ApplicationLog] = [] + ) { + 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 + ) + + // Application Logs Section + ApplicationLogPicker( + includeApplicationLogs: $includeApplicationLogs + ) + + // Contact Information Section + contactInformationSection + + // Submit Button Section + submitButtonSection + } + .navigationTitle(Localization.title) + .navigationBarTitleDisplayMode(.inline) + .alert(Localization.errorTitle, isPresented: $shouldShowErrorAlert) { + Button(Localization.gotIt) { + shouldShowErrorAlert = false + } + } message: { + Text(errorMessage) + } + .alert(Localization.supportRequestSent, isPresented: $shouldShowSuccessAlert) { + Button(Localization.gotIt) { + shouldShowSuccessAlert = false + onDismiss?() + } + } 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("We'll email you at this address.") + .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, *) { + 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, *) { + return self.attributedProblemDescription.toHtml() + } else { + return self.plainTextProblemDescription.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + /// Submits the support request + func submitSupportRequest() { + guard !submitButtonDisabled else { return } + + showLoadingIndicator = true + + Task { + do { + let conversation = try await self.dataProvider.createSupportConversation( + subject: self.subject, + message: self.getText(), + user: self.supportIdentity, + attachments: [] + ) + + await MainActor.run { + showLoadingIndicator = false + shouldShowSuccessAlert = true + } + } catch { + await MainActor.run { + 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 { + NavigationView { + 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/Sources/WordPressData/Swift/CoreDataHelper.swift b/Sources/WordPressData/Swift/CoreDataHelper.swift index 4d46c8760e3a..5d4c89450cf3 100644 --- a/Sources/WordPressData/Swift/CoreDataHelper.swift +++ b/Sources/WordPressData/Swift/CoreDataHelper.swift @@ -209,6 +209,13 @@ public extension CoreDataStack { } } + func performQuery(_ block: @escaping (NSManagedObjectContext) throws -> T) rethrows -> T { + let context = newDerivedContext() + return try context.performAndWait { + try block(context) + } + } + // MARK: - Database Migration /// Creates a copy of the current open store and saves it to the specified destination diff --git a/Sources/WordPressData/Swift/WPAccount+Lookup.swift b/Sources/WordPressData/Swift/WPAccount+Lookup.swift index df462b66c918..881ead59cc4d 100644 --- a/Sources/WordPressData/Swift/WPAccount+Lookup.swift +++ b/Sources/WordPressData/Swift/WPAccount+Lookup.swift @@ -50,6 +50,10 @@ public extension WPAccount { return try lookup(withUUIDString: uuid, in: context) } + static func lookupDefaultWordPressComAccountToken(in context: NSManagedObjectContext) throws -> String? { + try lookupDefaultWordPressComAccount(in: context)?.authToken + } + /// Lookup a WPAccount by its local uuid /// /// - Parameters: diff --git a/WordPress/Classes/Networking/WordPressDotComClient.swift b/WordPress/Classes/Networking/WordPressDotComClient.swift new file mode 100644 index 000000000000..4d40b7cc7bad --- /dev/null +++ b/WordPress/Classes/Networking/WordPressDotComClient.swift @@ -0,0 +1,111 @@ +import Foundation +import WordPressAPI +import WordPressAPIInternal +import Combine + +actor WordPressDotComClient { + + let api: WPComApiClient + + init() { + let session = URLSession(configuration: .ephemeral) + + let provider = AutoUpdatingWPComAuthenticationProvider(coreDataStack: ContextManager.shared) + let delegate = WpApiClientDelegate( + authProvider: .dynamic(dynamicAuthenticationProvider: provider), + requestExecutor: WpRequestExecutor(urlSession: session), + middlewarePipeline: WpApiMiddlewarePipeline(middlewares: [TmpDebugMiddleware()]), + appNotifier: WpComNotifier() + ) + + self.api = WPComApiClient(delegate: delegate) + } +} + +final class AutoUpdatingWPComAuthenticationProvider: @unchecked Sendable, WpDynamicAuthenticationProvider { + private let lock = NSLock() + private var authentication: WpAuthentication + + private let coreDataStack: CoreDataStack + + private var cancellable: AnyCancellable? + + init(coreDataStack: CoreDataStack) { + self.coreDataStack = coreDataStack + self.authentication = Self.readAuthentication(on: coreDataStack) + + self.cancellable = NotificationCenter.default.publisher(for: SelfHostedSiteAuthenticator.applicationPasswordUpdated).sink { [weak self] _ in + self?.update() + } + } + + @discardableResult + func update() -> WpAuthentication { + + let authentication = Self.readAuthentication(on: coreDataStack) + + // This line does not require `self.lock`. Putting it behind the `self.lock` may lead to dead lock, because + // `coreDataStack.performQuery` also aquire locks. + + self.lock.lock() + defer { + self.lock.unlock() + } + + self.authentication = authentication + + return authentication + } + + private static func readAuthentication(on stack: CoreDataStack) -> WpAuthentication { + do { + guard let authToken = try stack.performQuery({ + try WPAccount.lookupDefaultWordPressComAccountToken(in: $0) + }) + else { + return .none + } + + return .bearer(token: authToken) + } catch { +// wpAssertionFailure("Failed to read auth token") + return .none + } + } + + func auth() -> WordPressAPIInternal.WpAuthentication { + lock.lock() + defer { + lock.unlock() + } + + return self.authentication + } + + func refresh() async -> Bool { + return false // WP.com doesn't support programmatically refreshing the auth token + } +} + +final class WpComNotifier: WpAppNotifier { + static let notificationName = Notification.Name("wpcom-invalid-authentication-provided") + + func requestedWithInvalidAuthentication(requestUrl: String) async { + NotificationCenter.default.post(name: Self.notificationName, object: nil) + } +} + +public final class TmpDebugMiddleware: WpApiMiddleware { + public func process( + requestExecutor: any WordPressAPIInternal.RequestExecutor, + response: WordPressAPIInternal.WpNetworkResponse, + request: WordPressAPIInternal.WpNetworkRequest, + context: WordPressAPIInternal.RequestContext? + ) async throws -> WordPressAPIInternal.WpNetworkResponse { + debugPrint("Performed request: \(request.url())") + debugPrint("Request Headers: \(request.headerMap().toFlatMap())") + debugPrint("Body: \(String(describing: request.bodyAsString()))") + debugPrint("Received response: \(response)") + return response + } +} diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index 3d42a2946c47..91e9b8a463b9 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -27,6 +27,7 @@ public enum FeatureFlag: Int, CaseIterable { case newStats case mediaQuotaView case intelligence + case newSupport /// Returns a boolean indicating if the feature is enabled. /// @@ -86,6 +87,8 @@ public enum FeatureFlag: Int, CaseIterable { case .intelligence: let languageCode = Locale.current.language.languageCode?.identifier return (languageCode ?? "en").hasPrefix("en") + case .newSupport: + return false } } @@ -130,6 +133,7 @@ extension FeatureFlag { case .newStats: "New Stats" case .mediaQuotaView: "Media Quota" case .intelligence: "Intelligence" + case .newSupport: "New Support" } } } diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift b/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift index f59c122b2dd9..bbf55962fe51 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift @@ -10,7 +10,8 @@ class ExperimentalFeaturesDataProvider: ExperimentalFeaturesViewModel.DataProvid FeatureFlag.newStats, FeatureFlag.allowApplicationPasswords, RemoteFeatureFlag.newGutenberg, - FeatureFlag.newGutenbergThemeStyles + FeatureFlag.newGutenbergThemeStyles, + FeatureFlag.newSupport, ] private let flagStore = FeatureFlagOverrideStore() diff --git a/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift b/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift index d94590ac3737..b4b44c510dbb 100644 --- a/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift @@ -4,6 +4,8 @@ import WordPressData import WordPressShared import AutomatticAbout import GravatarUI +import WordPressAPI +import Support public class MeViewController: UITableViewController { var handler: ImmuTableViewHandler! @@ -304,8 +306,13 @@ public class MeViewController: UITableViewController { func pushHelp() -> ImmuTableAction { return { [unowned self] row in - let controller = SupportTableViewController(style: .insetGrouped) - self.showOrPushController(controller) + if FeatureFlag.newSupport.enabled == true { + let controller = RootSupportViewController(dataProvider: SupportDataProvider.shared) + self.showOrPushController(controller) + } else { + let controller = SupportTableViewController(style: .insetGrouped) + self.showOrPushController(controller) + } } } diff --git a/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift new file mode 100644 index 000000000000..c78fbbe35656 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift @@ -0,0 +1,121 @@ +import SwiftUI +import Support +import WordPressAPIInternal +import WebKit + +struct RootSupportView: View { + + @EnvironmentObject + var dataProvider: SupportDataProvider + + @State + var dataLoadingError: Error? = nil + + @State + var userIdentity: SupportUser? = nil + + @State + var userIsEligibleForSupport: Bool = false + + var body: some View { + List { + Section("Support Profile") { + if let identity = self.userIdentity { + ProfileView(user: identity) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } else { + Button(role: nil) { + debugPrint("Start WP.com login") + } label: { + Text("Sign in with WordPress.com") + } + } + } + + Section("How can we help?") { + NavigationLink { + let url = URL(string: "https://apps.wordpress.com/support/")! + WebKitView(configuration: WebViewControllerConfiguration(url: url)) + } label: { + SubtitledListViewItem( + title: "Help Center", + subtitle: "Documentation and Tutorials to help you get started" + ) + } + + if let identity = self.userIdentity { + NavigationLink { + ConversationListView(currentUser: identity) + .environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller + } label: { + SubtitledListViewItem( + title: "Ask the bots", + subtitle: "Get quick answers to common questions" + ) + } + + NavigationLink { + SupportConversationListView(currentUser: identity) + .environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller + } label: { + SubtitledListViewItem( + title: "Ask the Happiness Engineers", + subtitle: "For your tough questions. We'll reply via email" + ) + } + } + } + + Section("Diagnostics") { + NavigationLink { + ActivityLogListView() + .environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller + } label: { + SubtitledListViewItem( + title: "Application Logs", + subtitle: "Advanced tool to debug issues" + ) + } + + NavigationLink { + Text("Site Status Report") + } label: { + SubtitledListViewItem( + title: "System Status Report", + subtitle: "Various system information about your site" + ) + } + } + } + .navigationTitle("Support") + .task { + do { + self.userIdentity = try await self.dataProvider.loadSupportIdentity() + } catch { + debugPrint(error.localizedDescription) + self.dataLoadingError = error + } + } + } +} + +class RootSupportViewController: UIHostingController { + + private let dataProvider: SupportDataProvider + + @MainActor + init(dataProvider: SupportDataProvider) { + self.dataProvider = dataProvider + let type = RootSupportView().environmentObject(self.dataProvider) + super.init(rootView: AnyView(erasing: type)) + } + + @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} +// +//#Preview { +// RootSupportView() +//} diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift new file mode 100644 index 000000000000..09fd225a3196 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -0,0 +1,341 @@ +import Foundation +import AsyncImageKit +import Support +import SwiftUI +import WordPressAPI +import WordPressAPIInternal // Needed for `SupportUserIdentity` +import WordPressData +import WordPressShared +import CocoaLumberjack + +extension SupportDataProvider { + @MainActor + static let shared = SupportDataProvider( + applicationLogProvider: WpLogDataProvider(), + botConversationDataProvider: WpBotConversationDataProvider( + wpcomClient: WordPressDotComClient() + ), + userDataProvider: WpCurrentUserDataProvider( + wpcomClient: WordPressDotComClient() + ), + supportConversationDataProvider: WpSupportConversationDataProvider( + wpcomClient: WordPressDotComClient()), + delegate: nil + ) +} + +actor WpLogDataProvider: ApplicationLogDataProvider { + func fetchApplicationLogs() async throws -> [Support.ApplicationLog] { + try WPLogger.shared().fileLogger + .logFileManager + .sortedLogFileInfos + .compactMap { try ApplicationLog(filePath: $0.filePath) } + } + + func deleteApplicationLogs(in logs: [Support.ApplicationLog]) async throws { + for log in logs { + try FileManager.default.removeItem(at: log.path) + } + } + + func deleteAllApplicationLogs() async throws { + WPLogger.shared().deleteAllLogs() + } +} + +actor WpBotConversationDataProvider: BotConversationDataProvider { + + private let botId = "jetpack-chat-mobile" + + private let wpcomClient: WordPressDotComClient + + private var conversationMessageStore: [UInt64: Support.BotConversation] = [:] + + init(wpcomClient: WordPressDotComClient) { + self.wpcomClient = wpcomClient + } + + func loadBotConversations() async throws -> [Support.BotConversation] { + try await self.wpcomClient + .api + .supportBots + .getBotConverationList(botId: self.botId) + .data + .map { $0.asSupportConversation() } + } + + func loadBotConversation(id: UInt64) async throws -> Support.BotConversation? { + let params = GetBotConversationParams( + pageNumber: 1, + itemsPerPage: 100, + includeFeedback: false + ) + + let conversation = try await self.wpcomClient + .api + .supportBots + .getBotConversation(botId: self.botId, chatId: ChatId(id), params: params) + .data + + return conversation.asSupportConversation() + } + + func delete(conversationIds: [UInt64]) async throws { + // TODO: Implement this + } + + func sendMessage(message: String, in conversation: Support.BotConversation?) async throws -> Support.BotConversation { + if let conversation { + _ = try await add(message: message, to: conversation) + return try await loadBotConversation(id: conversation.id) ?? conversation + } else { + return try await createConversation(message: message) + } + } + + func createConversation(message: String) async throws -> Support.BotConversation { + + guard let accountId = try await ContextManager.shared + .performQuery({ try WPAccount.lookupDefaultWordPressComAccount(in: $0)?.userID?.int64Value }) else { + fatalError("Could not get the current user ID – this should never happen because users should be logged in") + } + + let params: CreateBotConversationParams = CreateBotConversationParams( + message: message, + userId: accountId + ) + + let response = try await self.wpcomClient + .api + .supportBots + .createBotConversation(botId: self.botId, params: params) + .data + + return response.asSupportConversation() + } + + private func add(message: String, to conversation: Support.BotConversation) async throws -> Support.BotConversation { + let params: AddMessageToBotConversationParams = AddMessageToBotConversationParams( + message: message, + context: [:] + ) + + let response = try await self.wpcomClient + .api + .supportBots + .addMessageToBotConversation( + botId: self.botId, + chatId: ChatId(conversation.id), + params: params + ).data + + return response.asSupportConversation() + } +} + +actor WpCurrentUserDataProvider: CurrentUserDataProvider { + + private let wpcomClient: WordPressDotComClient + private var cachedCurrentSupportUser: Support.SupportUser? + + init(wpcomClient: WordPressDotComClient) { + self.wpcomClient = wpcomClient + } + + func fetchCurrentSupportUser() async throws -> Support.SupportUser { + if let cachedCurrentSupportUser { + return cachedCurrentSupportUser + } + + let user = try await self.wpcomClient.api.me.get().data.asSupportIdentity() + cachedCurrentSupportUser = user + return user + } +} + +actor WpSupportConversationDataProvider: SupportConversationDataProvider { + + private let wpcomClient: WordPressDotComClient + + init(wpcomClient: WordPressDotComClient) { + self.wpcomClient = wpcomClient + } + + func loadSupportConversations() async throws -> [ConversationSummary] { + try await self.wpcomClient.api + .supportTickets + .getSupportConversationList() + .data + .map { $0.asConversationSummary() } + } + + func loadSupportConversation(id: UInt64) async throws -> Conversation { + try await self.wpcomClient.api + .supportTickets + .getSupportConversation(conversationId: id) + .data + .asConversation() + } + + func createSupportConversation( + subject: String, + message: String, + user: SupportUser, + attachments: [URL] + ) async throws -> Conversation { + let params = CreateSupportTicketParams( + subject: subject, + message: message, + application: "jetpack" + ) + + 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() } + ) + + return try await self.wpcomClient.api + .supportTickets + .addMessageToSupportConversation(conversationId: id, params: params) + .data + .asConversation() + } +} + +extension WPComApiClient: @retroactive @unchecked Sendable {} + +extension WpComUserInfo { + func asSupportIdentity() async throws -> SupportUser { + SupportUser( + userId: self.id, + username: self.displayName, + email: self.email, + avatarUrl: self.getAvatarUrl(), + ) + } + + func getAvatarUrl() -> URL? { + guard let urlString = self.avatarUrl, let url = URL(string: urlString) else { + return nil + } + + return url + } +} + +extension WordPressAPIInternal.BotConversationSummary { + func asSupportConversation() -> Support.BotConversation { + var summary = self.lastMessage.content + + if let preview = summary.components(separatedBy: .newlines).first?.prefix(64) { + summary = String(preview) + } + + return BotConversation( + id: self.chatId, + title: summary, + mostRecentMessageDate: self.lastMessage.createdAt, + messages: [] + ) + } +} + +extension WordPressAPIInternal.BotConversation { + func asSupportConversation() -> Support.BotConversation { + BotConversation( + id: self.chatId, + title: self.messages.first?.content ?? "New Bot Chat", + mostRecentMessageDate: self.messages.last?.createdAt ?? self.createdAt, + messages: self.messages.map { $0.asSupportMessage() } + ) + } +} + +extension WordPressAPIInternal.BotMessage { + func asSupportMessage() -> Support.BotMessage { + return switch context { + + case .bot(let botContext): Support.BotMessage( + id: self.messageId, + text: self.content, + date: self.createdAt, + userWantsToTalkToHuman: botContext.userWantsToTalkToAHuman, + isWrittenByUser: false + ) + case .user: Support.BotMessage( + id: self.messageId, + text: self.content, + date: self.createdAt, + userWantsToTalkToHuman: false, + isWrittenByUser: true + ) + } + } +} + +extension WordPressAPIInternal.SupportConversationSummary { + func asConversationSummary() -> Support.ConversationSummary { + Support.ConversationSummary( + id: self.id, + title: self.title, + description: self.description, + lastMessageSentAt: self.updatedAt + ) + } +} + +extension SupportConversation { + func asConversation() -> Conversation { + Conversation( + id: self.id, + title: self.title, + description: self.description, + lastMessageSentAt: self.updatedAt, + 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.map { $0.asAttachment() } + ) + case .supportAgent(let agent): Message( + id: self.id, + content: self.content, + createdAt: self.createdAt, + authorName: agent.name, + authorIsUser: false, + attachments: self.attachments.map { $0.asAttachment() } + ) + } + } +} + +extension SupportAttachment { + func asAttachment() -> Attachment { + Attachment( + id: self.id + ) + } +}