From 40339476f953914d96e628b21e46b759cd9118a7 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 3 Oct 2025 00:00:48 -0600 Subject: [PATCH 01/10] Add new support area --- Modules/Package.resolved | 6 +- Modules/Package.swift | 10 +- .../Support/Extensions/Foundation.swift | 82 ++++ .../Sources/Support/Extensions/SwiftUI.swift | 5 + .../Support/InternalDataProvider.swift | 378 +++++++++++++++++ Modules/Sources/Support/Localization.swift | 219 ++++++++++ .../Support/Model/ApplicationLog.swift | 53 +++ .../Support/Model/BotConversation.swift | 28 ++ .../Sources/Support/Model/BotMessage.swift | 31 ++ .../Support/Model/SupportConversation.swift | 100 +++++ .../Support/Model/SupportFormArea.swift | 33 ++ .../Sources/Support/Model/SupportUser.swift | 28 ++ .../Sources/Support/SupportDataProvider.swift | 200 +++++++++ .../ActivityLogDetailView.swift | 131 ++++++ .../ActivityLogListView.swift | 173 ++++++++ .../ActivityLogSharingView.swift | 152 +++++++ .../SubtitledListViewItem.swift | 43 ++ .../Bot Conversations/CompositionView.swift | 87 ++++ .../ConversationBotIntro.swift | 41 ++ .../ConversationListView.swift | 153 +++++++ .../Bot Conversations/ConversationView.swift | 319 ++++++++++++++ .../UI/Bot Conversations/MessageView.swift | 37 ++ .../UI/Bot Conversations/ThinkingView.swift | 27 ++ Modules/Sources/Support/UI/ErrorView.swift | 85 ++++ .../Sources/Support/UI/FX/ShimmerEffect.swift | 73 ++++ Modules/Sources/Support/UI/ProfileView.swift | 126 ++++++ .../ApplicationLogPicker.swift | 138 ++++++ .../ScreenshotPicker.swift | 172 ++++++++ .../SupportConversationListView.swift | 139 ++++++ .../SupportConversationReplyView.swift | 225 ++++++++++ .../SupportConversationView.swift | 268 ++++++++++++ .../Support Conversations/SupportForm.swift | 394 ++++++++++++++++++ .../WordPressData/Swift/CoreDataHelper.swift | 7 + .../Swift/WPAccount+Lookup.swift | 4 + .../Networking/WordPressDotComClient.swift | 111 +++++ .../BuildInformation/FeatureFlag.swift | 4 + .../ExperimentalFeaturesDataProvider.swift | 3 +- .../Me/Me Main/MeViewController.swift | 11 +- .../NewSupport/RootSupportView.swift | 121 ++++++ .../NewSupport/SupportDataProvider.swift | 341 +++++++++++++++ 40 files changed, 4551 insertions(+), 7 deletions(-) create mode 100644 Modules/Sources/Support/Extensions/Foundation.swift create mode 100644 Modules/Sources/Support/Extensions/SwiftUI.swift create mode 100644 Modules/Sources/Support/InternalDataProvider.swift create mode 100644 Modules/Sources/Support/Localization.swift create mode 100644 Modules/Sources/Support/Model/ApplicationLog.swift create mode 100644 Modules/Sources/Support/Model/BotConversation.swift create mode 100644 Modules/Sources/Support/Model/BotMessage.swift create mode 100644 Modules/Sources/Support/Model/SupportConversation.swift create mode 100644 Modules/Sources/Support/Model/SupportFormArea.swift create mode 100644 Modules/Sources/Support/Model/SupportUser.swift create mode 100644 Modules/Sources/Support/SupportDataProvider.swift create mode 100644 Modules/Sources/Support/UI/Application Logs/ActivityLogDetailView.swift create mode 100644 Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift create mode 100644 Modules/Sources/Support/UI/Application Logs/ActivityLogSharingView.swift create mode 100644 Modules/Sources/Support/UI/Application Logs/SubtitledListViewItem.swift create mode 100644 Modules/Sources/Support/UI/Bot Conversations/CompositionView.swift create mode 100644 Modules/Sources/Support/UI/Bot Conversations/ConversationBotIntro.swift create mode 100644 Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift create mode 100644 Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift create mode 100644 Modules/Sources/Support/UI/Bot Conversations/MessageView.swift create mode 100644 Modules/Sources/Support/UI/Bot Conversations/ThinkingView.swift create mode 100644 Modules/Sources/Support/UI/ErrorView.swift create mode 100644 Modules/Sources/Support/UI/FX/ShimmerEffect.swift create mode 100644 Modules/Sources/Support/UI/ProfileView.swift create mode 100644 Modules/Sources/Support/UI/Support Conversations/ApplicationLogPicker.swift create mode 100644 Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift create mode 100644 Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift create mode 100644 Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift create mode 100644 Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift create mode 100644 Modules/Sources/Support/UI/Support Conversations/SupportForm.swift create mode 100644 WordPress/Classes/Networking/WordPressDotComClient.swift create mode 100644 WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift create mode 100644 WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift 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 + ) + } +} From 52a383d680a5f90f3d3d9c1584248f19345120ac Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 17 Oct 2025 19:34:39 -0600 Subject: [PATCH 02/10] Add cache for support --- Modules/Package.swift | 3 +- .../Support/Extensions/Foundation.swift | 14 + .../Support/InternalDataProvider.swift | 51 ++- .../Support/Model/SupportConversation.swift | 8 +- .../Sources/Support/Model/SupportUser.swift | 5 +- .../Sources/Support/SupportDataProvider.swift | 43 +- .../ActivityLogDetailView.swift | 2 +- .../ActivityLogListView.swift | 18 +- .../ActivityLogSharingView.swift | 2 +- .../Bot Conversations/CompositionView.swift | 58 ++- .../ConversationListView.swift | 182 ++++++--- .../Bot Conversations/ConversationView.swift | 374 +++++++++++++----- .../UI/Bot Conversations/MessageView.swift | 2 +- .../UI/Diagnostics/DiagnosticCard.swift | 39 ++ .../UI/Diagnostics/DiagnosticsView.swift | 26 ++ .../UI/Diagnostics/EmptyDiskCacheView.swift | 160 ++++++++ Modules/Sources/Support/UI/ErrorView.swift | 6 +- .../Support/UI/LoadingLatestContentView.swift | 53 +++ Modules/Sources/Support/UI/ProfileView.swift | 44 ++- .../SupportConversationListView.swift | 39 +- .../SupportConversationView.swift | 74 +++- .../Support Conversations/SupportForm.swift | 2 +- .../CachedAndFetchedResult.swift | 97 +++++ Modules/Sources/WordPressCore/DiskCache.swift | 91 +++++ .../WordPressCore/WordPressClient.swift | 1 - WordPress/Classes/Utility/AccountHelper.swift | 4 + .../NewSupport/RootSupportView.swift | 245 ++++++++---- .../NewSupport/SupportDataProvider.swift | 128 +++--- 28 files changed, 1405 insertions(+), 366 deletions(-) create mode 100644 Modules/Sources/Support/UI/Diagnostics/DiagnosticCard.swift create mode 100644 Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift create mode 100644 Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift create mode 100644 Modules/Sources/Support/UI/LoadingLatestContentView.swift create mode 100644 Modules/Sources/WordPressCore/CachedAndFetchedResult.swift create mode 100644 Modules/Sources/WordPressCore/DiskCache.swift diff --git a/Modules/Package.swift b/Modules/Package.swift index 9f58157b4afc..56e09fb13add 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -136,7 +136,8 @@ let package = Package( .target( name: "Support", dependencies: [ - "AsyncImageKit" + "AsyncImageKit", + "WordPressCore", ] ), .target(name: "TextBundle"), diff --git a/Modules/Sources/Support/Extensions/Foundation.swift b/Modules/Sources/Support/Extensions/Foundation.swift index cac974dd65eb..a7e0fa8d3960 100644 --- a/Modules/Sources/Support/Extensions/Foundation.swift +++ b/Modules/Sources/Support/Extensions/Foundation.swift @@ -80,3 +80,17 @@ func convertMarkdownTextToAttributedString(_ text: String) -> AttributedString { return AttributedString(text) } } + +extension Task where Failure == Error { + static func delayedAndRunOnMainActor( + for duration: C.Instant.Duration, + priority: TaskPriority? = nil, + operation: @MainActor @escaping @Sendable () throws -> Success, + clock: C = .continuous + ) -> Task where C: Clock { + Task(priority: priority) { + try await clock.sleep(for: duration) + return try await MainActor.run(body: operation) + } + } +} diff --git a/Modules/Sources/Support/InternalDataProvider.swift b/Modules/Sources/Support/InternalDataProvider.swift index 9e574d98729e..116fc03b8a0c 100644 --- a/Modules/Sources/Support/InternalDataProvider.swift +++ b/Modules/Sources/Support/InternalDataProvider.swift @@ -1,4 +1,5 @@ import Foundation +import WordPressCore // This file is all module-internal and provides sample data for UI development @@ -14,7 +15,8 @@ extension SupportDataProvider { static let supportUser = SupportUser( userId: 1234, username: "demo-user", - email: "test@example.com" + email: "test@example.com", + permissions: [.createChatConversation, .createSupportRequest] ) static let botConversation = BotConversation( id: 1234, @@ -271,16 +273,20 @@ actor InternalBotConversationDataProvider: BotConversationDataProvider { await SupportDataProvider.supportUser } - func loadBotConversations() async throws -> [BotConversation] { - [await SupportDataProvider.botConversation] + func loadBotConversations() async throws -> any CachedAndFetchedResult<[BotConversation]> { + UncachedResult { + [await SupportDataProvider.botConversation] + } } - func loadBotConversation(id: UInt64) async throws -> BotConversation? { - if id == 5678 { - return await SupportDataProvider.conversationReferredToHuman - } + func loadBotConversation(id: UInt64) async throws -> any CachedAndFetchedResult { + UncachedResult { + if id == 5678 { + return await SupportDataProvider.conversationReferredToHuman + } - return await SupportDataProvider.botConversation + return await SupportDataProvider.botConversation + } } func delete(conversationIds: [UInt64]) async throws { @@ -309,23 +315,28 @@ actor InternalBotConversationDataProvider: BotConversationDataProvider { } actor InternalUserDataProvider: CurrentUserDataProvider { - func fetchCurrentSupportUser() async throws -> SupportUser { - await SupportDataProvider.supportUser + func fetchCurrentSupportUser() async throws -> any CachedAndFetchedResult { + UncachedResult { + 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 loadSupportConversations() async throws -> any CachedAndFetchedResult<[ConversationSummary]> { + UncachedResult { + return await SupportDataProvider.supportConversationSummaries + } } - func loadSupportConversation(id: UInt64) async throws -> Conversation { - let conversation = await SupportDataProvider.supportConversation - self.conversations[id] = conversation - return conversation + func loadSupportConversation(id: UInt64) async throws -> any CachedAndFetchedResult { + UncachedResult { + let conversation = await SupportDataProvider.supportConversation + await self.cache(conversation) + return conversation + } } func replyToSupportConversation( @@ -335,7 +346,7 @@ actor InternalSupportConversationDataProvider: SupportConversationDataProvider { attachments: [URL] ) async throws -> Conversation { - let conversation = try await loadSupportConversation(id: id) + let conversation = try await loadSupportConversation(id: id).fetchedResult() if Bool.random() { throw CocoaError(.validationInvalidDate) @@ -375,4 +386,8 @@ actor InternalSupportConversationDataProvider: SupportConversationDataProvider { )] ) } + + private func cache(_ value: Conversation) { + self.conversations[value.id] = value + } } diff --git a/Modules/Sources/Support/Model/SupportConversation.swift b/Modules/Sources/Support/Model/SupportConversation.swift index 5a1cb936f607..753ae500240c 100644 --- a/Modules/Sources/Support/Model/SupportConversation.swift +++ b/Modules/Sources/Support/Model/SupportConversation.swift @@ -1,6 +1,6 @@ import Foundation -public struct ConversationSummary: Identifiable, Sendable { +public struct ConversationSummary: Identifiable, Hashable, Sendable, Codable { public let id: UInt64 public let title: String public let description: String @@ -25,7 +25,7 @@ public struct ConversationSummary: Identifiable, Sendable { } } -public struct Conversation: Identifiable, Sendable { +public struct Conversation: Identifiable, Sendable, Codable { public let id: UInt64 public let title: String public let description: String @@ -57,7 +57,7 @@ public struct Conversation: Identifiable, Sendable { } } -public struct Message: Identifiable, Sendable { +public struct Message: Identifiable, Sendable, Codable { public let id: UInt64 public let content: String @@ -91,7 +91,7 @@ public struct Message: Identifiable, Sendable { } } -public struct Attachment: Identifiable, Sendable { +public struct Attachment: Identifiable, Sendable, Codable { public let id: UInt64 public init(id: UInt64) { diff --git a/Modules/Sources/Support/Model/SupportUser.swift b/Modules/Sources/Support/Model/SupportUser.swift index 5889f2fc4b33..0cb7732c2ab2 100644 --- a/Modules/Sources/Support/Model/SupportUser.swift +++ b/Modules/Sources/Support/Model/SupportUser.swift @@ -1,21 +1,24 @@ import Foundation import CryptoKit -public struct SupportUser: Sendable { +public struct SupportUser: Sendable, Codable { public let userId: UInt64 public let username: String public let email: String + public let permissions: [SupportUserPermission] public let avatarUrl: URL public init( userId: UInt64, username: String, email: String, + permissions: [SupportUserPermission] = [], avatarUrl: URL? = nil ) { self.userId = userId self.username = username self.email = email + self.permissions = permissions if let avatarUrl { self.avatarUrl = avatarUrl diff --git a/Modules/Sources/Support/SupportDataProvider.swift b/Modules/Sources/Support/SupportDataProvider.swift index da6b9f0c102e..2a172ad1bba0 100644 --- a/Modules/Sources/Support/SupportDataProvider.swift +++ b/Modules/Sources/Support/SupportDataProvider.swift @@ -1,9 +1,20 @@ import Foundation +import WordPressCore public enum SupportFormAction { case viewSupportForm } +public enum DiagnosticAction { + case clearDiskCache +} + +public enum DiagnosticActionStatus { + case running(progress: Float) + case success + case error(Error) +} + @MainActor public final class SupportDataProvider: ObservableObject, Sendable { @@ -33,17 +44,21 @@ public final class SupportDataProvider: ObservableObject, Sendable { self.supportDelegate?.userDid(action) } + public func userDid(_ action: DiagnosticAction, progress: @escaping (DiagnosticActionStatus) -> Void) { + self.supportDelegate?.userDid(action, progress: progress) + } + // Support Bots Data Source - public func loadSupportIdentity() async throws -> SupportUser { + public func loadSupportIdentity() async throws -> any CachedAndFetchedResult { try await self.userDataProvider.fetchCurrentSupportUser() } // Bot Conversation Data Source - public func loadConversations() async throws -> [BotConversation] { + public func loadConversations() async throws -> any CachedAndFetchedResult<[BotConversation]> { try await self.botConversationDataProvider.loadBotConversations() } - public func loadConversation(id: UInt64) async throws -> BotConversation? { + public func loadConversation(id: UInt64) async throws -> any CachedAndFetchedResult { try await self.botConversationDataProvider.loadBotConversation(id: id) } @@ -51,16 +66,16 @@ public final class SupportDataProvider: ObservableObject, Sendable { try await self.botConversationDataProvider.delete(conversationIds: conversationIds) } - public func sendMessage(message: String, in conversation: BotConversation?) async throws -> BotConversation { + public func sendMessage(message: String, in conversation: BotConversation? = nil) async throws -> BotConversation { try await self.botConversationDataProvider.sendMessage(message: message, in: conversation) } // Support Conversations Data Source - public func loadSupportConversations() async throws -> [ConversationSummary] { + public func loadSupportConversations() async throws -> any CachedAndFetchedResult<[ConversationSummary]> { try await self.supportConversationDataProvider.loadSupportConversations() } - public func loadSupportConversation(id: UInt64) async throws -> Conversation { + public func loadSupportConversation(id: UInt64) async throws -> any CachedAndFetchedResult { try await self.supportConversationDataProvider.loadSupportConversation(id: id) } @@ -147,10 +162,16 @@ extension SupportFormDataProvider { public protocol SupportDelegate: NSObject { func userDid(_ action: SupportFormAction) + func userDid(_ action: DiagnosticAction, progress: (DiagnosticActionStatus) -> Void) +} + +public enum SupportUserPermission: Sendable, Codable { + case createChatConversation + case createSupportRequest } public protocol CurrentUserDataProvider: Actor { - func fetchCurrentSupportUser() async throws -> SupportUser + func fetchCurrentSupportUser() async throws -> any CachedAndFetchedResult } public protocol ApplicationLogDataProvider: Actor { @@ -173,16 +194,16 @@ public extension ApplicationLogDataProvider { } public protocol BotConversationDataProvider: Actor { - func loadBotConversations() async throws -> [BotConversation] - func loadBotConversation(id: UInt64) async throws -> BotConversation? + func loadBotConversations() async throws -> any CachedAndFetchedResult<[BotConversation]> + func loadBotConversation(id: UInt64) async throws -> any CachedAndFetchedResult 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 loadSupportConversations() async throws -> any CachedAndFetchedResult<[ConversationSummary]> + func loadSupportConversation(id: UInt64) async throws -> any CachedAndFetchedResult func replyToSupportConversation( id: UInt64, diff --git a/Modules/Sources/Support/UI/Application Logs/ActivityLogDetailView.swift b/Modules/Sources/Support/UI/Application Logs/ActivityLogDetailView.swift index 64ba0d6f7e09..0ca081815d4b 100644 --- a/Modules/Sources/Support/UI/Application Logs/ActivityLogDetailView.swift +++ b/Modules/Sources/Support/UI/Application Logs/ActivityLogDetailView.swift @@ -124,7 +124,7 @@ struct ActivityLogDetailView: View { } #Preview { - NavigationView { + NavigationStack { 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 index da03b29ed4e7..b0dfe3692814 100644 --- a/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift +++ b/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift @@ -43,15 +43,7 @@ public struct ActivityLogListView: View { } .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") - } - } - + if case .loaded(_, let deletionState) = self.state { switch deletionState { case .none: EmptyView() // Do nothing case .deleting: ProgressView() @@ -110,6 +102,12 @@ public struct ActivityLogListView: View { self.isConfirmingDeletion = true } } + } else { + ContentUnavailableView { + Label("No Logs Found", systemImage: "doc.text") + } description: { + Text("There are no activity logs available") + } } } @@ -166,7 +164,7 @@ public struct ActivityLogListView: View { } #Preview { - NavigationView { + NavigationStack { ActivityLogListView() .environmentObject(SupportDataProvider.testing) } diff --git a/Modules/Sources/Support/UI/Application Logs/ActivityLogSharingView.swift b/Modules/Sources/Support/UI/Application Logs/ActivityLogSharingView.swift index 9ac3beb2ba8e..57c53c1ad8ad 100644 --- a/Modules/Sources/Support/UI/Application Logs/ActivityLogSharingView.swift +++ b/Modules/Sources/Support/UI/Application Logs/ActivityLogSharingView.swift @@ -33,7 +33,7 @@ struct ActivityLogSharingView: View { var destination: () -> AnyView var body: some View { - NavigationView { + NavigationStack { VStack(alignment: .leading, spacing: 24) { VStack(spacing: 12) { diff --git a/Modules/Sources/Support/UI/Bot Conversations/CompositionView.swift b/Modules/Sources/Support/UI/Bot Conversations/CompositionView.swift index db677709a268..b610cf2380e0 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/CompositionView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/CompositionView.swift @@ -4,16 +4,26 @@ struct CompositionView: View { private let cornerSize: CGSize = CGSize(width: 9, height: 8) + private let action: (String) -> Void + private let isDisabled: Bool + @State - var text = "" + private var text = "" @State - var disabled: Bool = false + private var textIsEmpty: Bool = false + + private var sendButtonIsDisabled: Bool { + self.isDisabled || self.textIsEmpty + } @FocusState private var textFieldIsFocused: Bool - var action: (String) -> Void + init(isDisabled: Bool, action: @escaping (String) -> Void) { + self.isDisabled = isDisabled + self.action = action + } var body: some View { HStack(alignment: .center, spacing: 8) { @@ -28,23 +38,25 @@ struct CompositionView: View { .clipShape(RoundedRectangle(cornerSize: self.cornerSize)) } - Button(action: { - let copy = self.text - self.text = "" - self.textFieldIsFocused = false - self.action(copy) - }) { + Button(action: self.triggerAction) { Image(systemName: "arrow.up") .font(.system(size: 16, weight: .medium)) - .foregroundColor(.white) + .foregroundColor(self.sendButtonIsDisabled ? Color(.systemGray6) : .white) .frame(width: 32, height: 32) - .background(Color.accentColor) + .background(self.sendButtonIsDisabled ? Color(.systemGray3) : Color.accentColor) .clipShape(RoundedRectangle(cornerSize: self.cornerSize)) } - .disabled(self.disabled || self.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(self.sendButtonIsDisabled) } .padding(.horizontal, 16) .padding(.vertical, 8) + .onChange(of: self.text, initial: true, { oldValue, newValue in + withAnimation { + textIsEmpty = newValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + } + }) } @ViewBuilder @@ -55,14 +67,28 @@ struct CompositionView: View { .padding(.horizontal, 20) .padding(.vertical, 16) .focused($textFieldIsFocused) + .onSubmit(of: .text) { + self.triggerAction() + } + } + + private func triggerAction() { + guard !self.sendButtonIsDisabled else { + return + } + + let copy = self.text + self.text = "" + self.textFieldIsFocused = false + self.action(copy) } } #Preview { - NavigationView { + NavigationStack { VStack { Spacer() - CompositionView { message in + CompositionView(isDisabled: false) { message in debugPrint(message) // Do nothing } @@ -71,14 +97,14 @@ struct CompositionView: View { } #Preview { - NavigationView { + NavigationStack { ZStack { List(SupportDataProvider.botConversation.messages) { Text($0.text) } VStack { Spacer() - CompositionView { message in + CompositionView(isDisabled: false) { message in // Do nothing } } diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift index f90d619e794b..3c48e2319bf8 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift @@ -3,9 +3,64 @@ import SwiftUI public struct ConversationListView: View { enum ViewState { - case loadingConversations + case loading + case partiallyLoaded([BotConversation]) + case loaded([BotConversation], ViewSubstate?) case loadingConversationsError(Error) - case ready + + var conversations: [BotConversation]? { + return switch self { + case .partiallyLoaded(let conversations): conversations + case .loaded(let conversations, _): conversations + default: nil + } + } + + var canDeleteConversations: Bool { + switch self { + case .loaded: true + default: false + } + } + + func addSubstate(_ newValue: ViewSubstate) -> Self { + guard case .loaded(let conversations, let oldValue) = self else { + preconditionFailure("You cannot transition to a substate unless the current state is `loaded`") + } + + guard case .none = oldValue else { + preconditionFailure("You cannot add a substate – one already exists") + } + + return .loaded(conversations, newValue) + } + + func updateSubstate(_ newValue: ViewSubstate) -> Self { + guard case .loaded(let conversations, let oldValue) = self else { + preconditionFailure("You cannot transition to a substate unless the current state is `loaded`") + } + + guard oldValue != nil else { + preconditionFailure("You cannot update to a new substate – none exists") + } + + return .loaded(conversations, newValue) + } + + func clearSubstate() -> Self { + guard case .loaded(let conversations, let oldValue) = self else { + preconditionFailure("You cannot clear substate unless the current state is `loaded`") + } + + guard oldValue != nil else { + preconditionFailure("You cannot clear substate – none exists") + } + + return .loaded(conversations, nil) + } + } + + enum ViewSubstate { case deletingConversations(Task) case deletingConversationsError(Error) } @@ -14,17 +69,11 @@ public struct ConversationListView: View { private var dataProvider: SupportDataProvider @State - var conversations: [BotConversation] = [] - - @State - var state: ViewState = .loadingConversations + var state: ViewState = .loading @State var selectedConversations = Set() - @State - private var deletionTask: Task? = nil - private let currentUser: SupportUser public init(currentUser: SupportUser) { @@ -32,26 +81,18 @@ public struct ConversationListView: View { } public var body: some View { - List(selection: $selectedConversations) { - - if case .loadingConversationsError(let error) = self.state { + VStack { + switch self.state { + case .loading: + ProgressView("Loading Bot Conversations") + case .partiallyLoaded(let conversations): self.conversationList(conversations) + case .loaded(let conversations, _): self.conversationList(conversations) + case .loadingConversationsError(let error): 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 { @@ -65,48 +106,97 @@ public struct ConversationListView: View { label: { Image(systemName: "square.and.pencil") } + .disabled(!currentUser.permissions.contains(.createChatConversation)) } } - .overlay { - if case .ready = state, self.conversations.isEmpty { - ContentUnavailableView { - Label("No Conversations", systemImage: "message") - } description: { - Text("Start a new conversation using the button above") + .overlay(content: { + if case .partiallyLoaded = state { + LoadingLatestContentView() + } + }) + .task(self.loadConversations) + .refreshable(action: self.reloadConversations) + } + + @ViewBuilder + private func conversationList(_ conversations: [BotConversation]) -> some View { + if case .loaded = self.state, conversations.isEmpty { + ContentUnavailableView { + Label("No Conversations", systemImage: "message") + } description: { + Text("Start a new conversation using the button above") + } + } else { + List(conversations) { conversation in + NavigationLink(destination: ConversationView( + conversation: conversation, + currentUser: currentUser + ).environmentObject(dataProvider)) { + ConversationRow(conversation: conversation) } } } - .refreshable { - await self.reloadConversations() - } - .task { - await self.reloadConversations() + } + + private func loadConversations() async { + do { + let fetch = try await dataProvider.loadConversations() + + if let cachedConversations = try await fetch.cachedResult() { + debugPrint("💬 Finished fetching cached conversations") + + await MainActor.run { + self.state = .partiallyLoaded(cachedConversations) + } + } + + let fetchedConversations = try await fetch.fetchedResult() + + debugPrint("💬 Finished fetching conversations") + + await MainActor.run { + self.state = .loaded(fetchedConversations, .none) + } + + } catch { + debugPrint("🚩 Load conversations error: \(error.localizedDescription)") + await MainActor.run { + self.state = .loadingConversationsError(error) + } } } private func reloadConversations() async { - self.state = .loadingConversations - do { - self.conversations = try await self.dataProvider.loadConversations() - self.state = .ready + let conversationList = try await self.dataProvider.loadConversations().fetchedResult() + await MainActor.run { + self.state = .loaded(conversationList, .none) + } } catch { - self.state = .loadingConversationsError(error) + await MainActor.run { + self.state = .loadingConversationsError(error) + } } } private func deleteConversations(at indexSet: IndexSet) { - let conversationIds = indexSet.map { conversations[$0].id } + guard let conversationIds = self.state.conversations?.map({ $0.id }) else { + return + } - self.state = .deletingConversations(Task { + self.state = self.state.addSubstate(.deletingConversations(Task { do { try await self.dataProvider.delete(conversationIds: conversationIds) - self.state = .ready + await MainActor.run { + self.state = self.state.clearSubstate() + } } catch { - self.state = .deletingConversationsError(error) + await MainActor.run { + self.state = self.state.updateSubstate(.deletingConversationsError(error)) + } } - }) + })) } } @@ -140,7 +230,7 @@ struct ConversationRow: View { #Preview { - NavigationView { + NavigationStack { ConversationListView( currentUser: SupportDataProvider.supportUser ) diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift index a47c96fe8974..02d77c6726e1 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift @@ -3,39 +3,221 @@ import SwiftUI public struct ConversationView: View { enum ViewState: Equatable { - case idle + case start case loadingMessages case loadingMessagesError(Error) - case startingNewConversation + case partiallyLoaded(conversation: BotConversation) + case loaded(conversation: BotConversation, substate: ViewSubstate?) + case startingNewConversation(substate: ViewSubstate?) 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 + case (.start, .start): + true + case (.loadingMessages, .loadingMessages): + true + case (.loadingMessagesError, .loadingMessagesError): + true + case (.partiallyLoaded, .partiallyLoaded): + true + case (.loaded(_, let lhsSubstate), .loaded(_, let rhsSubstate)): + lhsSubstate == rhsSubstate + case (.startingNewConversation(let lhsSubstate), .startingNewConversation(let rhsSubstate)): + lhsSubstate == rhsSubstate + case (.conversationNotFound, .conversationNotFound): + true + default: + false + } + } + + var conversationTitle: String { + self.conversation?.title ?? "New Conversation" + } + + var conversation: BotConversation? { + return switch self { + case .partiallyLoaded(let conversation): conversation + case .loaded(conversation: let conversation, _): conversation + default: nil + } + } + + var messages: [BotMessage] { + switch self { + case .partiallyLoaded(let conversation): conversation.messages + case .loaded(conversation: let conversation, _): conversation.messages + default: [] + } + } + + var userWantsHumanSupport: Bool { + switch self { + case .partiallyLoaded(let conversation): conversation.userWantsHumanSupport + case .loaded(conversation: let conversation, _): conversation.userWantsHumanSupport default: false } } + + var shouldCauseScrollToBottom: Bool { + switch self { + case .loaded, .partiallyLoaded: true + default: false + } + } + + private var substate: ViewSubstate? { + if case .loaded(_, let substate) = self { + return substate + } + + if case .startingNewConversation(let substate) = self { + return substate + } + + return nil + } + + var inFlightMessage: String? { + self.substate?.inflightMessage + } + + var isSendingMessage: Bool { + inFlightMessage != nil + } + + var isThinking: Bool { + self.substate?.isThinking ?? false + } + + var isStartingNewConversation: Bool { + guard case .startingNewConversation = self else { + return false + } + + return true + } + + func transitioningToSendingMessage(message: String, task: Task) -> Self { + guard self.substate == nil else { + preconditionFailure("Cannot send message – operation already in progress") + } + + if let currentConversation = self.conversation { + return .loaded( + conversation: currentConversation, + substate: .sendingMessage( + message: message, + thinking: false, + task + ) + ) + } else { + return .startingNewConversation( + substate: .sendingMessage( + message: message, + thinking: false, + task + ) + ) + } + } + + func transitioningToThinking() -> Self { + + guard case .sendingMessage(let message, _, let task) = self.substate else { + preconditionFailure("Cannot transition to `thinking` because no message is in-flight") + } + + // If we're not sending a message, don't transition + guard self.isSendingMessage else { + return self + } + + if let currentConversation = self.conversation { + return .loaded( + conversation: currentConversation, + substate: .sendingMessage( + message: message, + thinking: true, + task + ) + ) + } else { + return .startingNewConversation( + substate: .sendingMessage( + message: message, + thinking: true, + task + ) + ) + } + } + + func transitioningToMessageSent(updatedConversation: BotConversation) -> Self { + guard self.isSendingMessage, self.substate != nil else { + preconditionFailure("Cannot transition to message sent – none is in-flight") + } + + return .loaded(conversation: updatedConversation, substate: nil) + } + + func transitioningToMessageSendError(_ error: Error) -> Self { + if let currentConversation = self.conversation { + guard self.substate != nil else { + preconditionFailure("Cannot transition to message send error – none is in-flight") + } + + return .loaded( + conversation: currentConversation, + substate: .sendingMessageError(error) + ) + } else { + guard self.substate != nil else { + preconditionFailure("Cannot transition to message send error – none is in-flight") + } + + return .startingNewConversation( + substate: .sendingMessageError(error) + ) + } + } + } + + enum ViewSubstate: Equatable { + case sendingMessage(message: String, thinking: Bool, Task) + case sendingMessageError(Error) + + static func == (lhs: ConversationView.ViewSubstate, rhs: ConversationView.ViewSubstate) -> Bool { + false // Force SwiftUI to re-evaluate everything anytime the ViewSubstate changes + } + + var isThinking: Bool { + if case .sendingMessage(_, let thinking, _) = self { + return thinking + } + + return false + } + + var inflightMessage: String? { + if case .sendingMessage(let message, _, _) = self { + return message + } + + return nil + } } @EnvironmentObject private var dataProvider: SupportDataProvider - @State - var conversation: BotConversation? - @State var currentUser: SupportUser @State - var state: ViewState = .idle + var state: ViewState = .start @State private var showThinkingView = false @@ -43,25 +225,12 @@ public struct ConversationView: View { @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 let conversationId: UInt64? private var loadingTask: Task? public init(conversation: BotConversation?, currentUser: SupportUser) { - self.conversation = conversation + self.conversationId = conversation?.id self.currentUser = currentUser } @@ -76,13 +245,11 @@ public struct ConversationView: View { loadingMessagesError Section { - ForEach(self.messages) { message in + ForEach(self.state.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) - } + sendingMessageView(proxy: proxy) } .listRowSeparator(.hidden) .listRowInsets(.zero) @@ -99,57 +266,39 @@ public struct ConversationView: View { .id(self.bottom) } .scrollDismissesKeyboard(.interactively) - .onAppear { - scrollToBottom(using: proxy, animated: false) + .onChange(of: self.state) { _, newState in + if newState.shouldCauseScrollToBottom { + scrollToBottom(using: proxy, animated: false) + } } } - .navigationTitle(self.title) + .navigationTitle(self.state.conversationTitle) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif VStack { Spacer() CompositionView( - disabled: self.isSendingMessage, + isDisabled: self.state.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 - } + .overlay(content: { + if case .partiallyLoaded = state { + LoadingLatestContentView() } - } + }) + .task(self.loadExistingConversation) + .refreshable(action: self.reloadConversation) } @ViewBuilder func sendingMessageView(proxy: ScrollViewProxy) -> some View { - if case .sendingMessage(let message, _) = self.state { + if let inFlightMessage = state.inFlightMessage { MessageView(message: BotMessage( id: 0, - text: message, + text: inFlightMessage, date: Date(), userWantsToTalkToHuman: false, isWrittenByUser: true @@ -163,7 +312,7 @@ public struct ConversationView: View { scrollToBottom(using: proxy, animated: true) } - if showThinkingView { + if self.state.isThinking { HStack { Spacer() ThinkingView() @@ -195,21 +344,24 @@ public struct ConversationView: View { @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 - )) + if case .loaded(_, let substate) = state { + if case .sendingMessageError(let error) = substate { + 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 { + + if state.userWantsHumanSupport { Section { // Deliberately left empty } footer: { @@ -252,25 +404,26 @@ public struct ConversationView: View { self.state = .loadingMessages do { - guard let conversationId = self.conversation?.id else { + guard let conversationId = self.conversationId else { await MainActor.run { - self.state = .startingNewConversation + self.state = .startingNewConversation(substate: nil) } return } - guard let conversation = try await self.dataProvider.loadConversation(id: conversationId) else { + let fetch = try await self.dataProvider.loadConversation(id: conversationId) + + if let cachedConversation = try await fetch.cachedResult() { await MainActor.run { - self.state = .conversationNotFound + self.state = .partiallyLoaded(conversation: cachedConversation) } - return } + let conversation = try await fetch.fetchedResult() + await MainActor.run { - self.conversation = conversation - self.state = .idle + self.state = .loaded(conversation: conversation, substate: nil) } - } catch { await MainActor.run { self.state = .loadingMessagesError(error) @@ -278,30 +431,53 @@ public struct ConversationView: View { } } + private func reloadConversation() async { + guard case .loaded(let conversation, _) = self.state else { + return + } + self.state = .partiallyLoaded(conversation: conversation) + } + private func sendMessage(_ message: String) { - let sendTask = Task { + self.state = self.state.transitioningToSendingMessage(message: message, task: Task { do { - let conversation = try await self.dataProvider.sendMessage( - message: message, - in: self.conversation - ) + + let thinkingTask = Task.delayedAndRunOnMainActor(for: .seconds(1.5)) { + self.state = self.state.transitioningToThinking() + } + + let updatedConversation: BotConversation + + if let currentConversation = self.state.conversation { + updatedConversation = try await self.dataProvider.sendMessage( + message: message, + in: currentConversation + ) + } else { + updatedConversation = try await self.dataProvider.sendMessage( + message: message + ) + } + + // If we somehow got a response before the thinking view shows up, don't show it + thinkingTask.cancel() await MainActor.run { - self.conversation = conversation - self.state = .idle + self.state = self.state.transitioningToMessageSent( + updatedConversation: updatedConversation + ) } } catch { - debugPrint("🚩 Error: \(error.localizedDescription)") - self.state = .sendingMessageError(error) + await MainActor.run { + self.state = self.state.transitioningToMessageSendError(error) + } } - } - - self.state = .sendingMessage(message, sendTask) + }) } } #Preview("Default chat") { - NavigationView { + NavigationStack { ConversationView( conversation: SupportDataProvider.botConversation, currentUser: SupportDataProvider.supportUser @@ -310,7 +486,7 @@ public struct ConversationView: View { } #Preview("User wants to chat with a human") { - NavigationView { + NavigationStack { ConversationView( conversation: SupportDataProvider.conversationReferredToHuman, currentUser: SupportDataProvider.supportUser diff --git a/Modules/Sources/Support/UI/Bot Conversations/MessageView.swift b/Modules/Sources/Support/UI/Bot Conversations/MessageView.swift index 1465a3ca6cf4..561aec946c94 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/MessageView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/MessageView.swift @@ -12,7 +12,7 @@ struct MessageView: View { VStack(alignment: message.isWrittenByUser ? .trailing : .leading, spacing: 4) { Text(message.attributedText) .padding(12) - .background(message.isWrittenByUser ? Color.blue : Color(.systemGray5)) + .background(message.isWrittenByUser ? Color.accentColor : Color(.systemGray5)) .foregroundColor(message.isWrittenByUser ? .white : .primary) .cornerRadius(16) diff --git a/Modules/Sources/Support/UI/Diagnostics/DiagnosticCard.swift b/Modules/Sources/Support/UI/Diagnostics/DiagnosticCard.swift new file mode 100644 index 000000000000..778d01e0bc50 --- /dev/null +++ b/Modules/Sources/Support/UI/Diagnostics/DiagnosticCard.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct DiagnosticCard: View { + let title: String + let subtitle: String + let systemImage: String + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: systemImage) + .font(.title2) + .symbolRenderingMode(.hierarchical) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + } + Spacer() + } + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + + content + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.thinMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(.quaternary, lineWidth: 0.5) + ) + } +} diff --git a/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift b/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift new file mode 100644 index 000000000000..3e59e3ba4a64 --- /dev/null +++ b/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift @@ -0,0 +1,26 @@ +import SwiftUI +import WordPressCore + +public struct DiagnosticsView: View { + + public init() {} + + public var body: some View { + ScrollView { + VStack(alignment: .leading) { + Text("Run common maintenance and troubleshooting tasks.") + .foregroundStyle(.secondary) + + EmptyDiskCacheView() + } + .padding() + } + .navigationTitle("Diagnostics") + .background(.background) + + } +} + +#Preview { + DiagnosticsView() +} diff --git a/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift b/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift new file mode 100644 index 000000000000..c1ac345d32d7 --- /dev/null +++ b/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift @@ -0,0 +1,160 @@ +import SwiftUI +import WordPressCore + +struct EmptyDiskCacheView: View { + + enum ViewState: Equatable { + case loading + case loaded(usage: DiskCache.DiskCacheUsage) + case clearing(progress: Double, result: String) + case error(Error) + + var isClearingCache: Bool { + if case .clearing = self { + return true + } + + return false + } + + var buttonIsDisabled: Bool { + if isClearingCache { + return true + } + + guard case .loaded(let usage) = self else { + return true + } + + return usage.isEmpty + } + + static func == (lhs: EmptyDiskCacheView.ViewState, rhs: EmptyDiskCacheView.ViewState) -> Bool { + switch(lhs, rhs) { + case (.loading, .loading): + return true + case (.loaded(let lhsUsage), .loaded(let rhsUsage)): + return lhsUsage == rhsUsage + case (.clearing(let lhsProgress, let lhsResult), .clearing(let rhsProgress, let rhsResult)): + return lhsProgress == rhsProgress && lhsResult == rhsResult + case (.error, .error): + return true + default: + return false + } + } + } + + @State + var state: ViewState = .loading + + private let cache = DiskCache() + + var body: some View { + // Clear Disk Cache card + DiagnosticCard( + title: "Clear Disk Cache", + subtitle: "Remove temporary files to free up space or resolve problems.", + systemImage: "externaldrive.badge.xmark" + ) { + VStack(alignment: .leading, spacing: 12) { + Button { + Task { await clearDiskCache() } + } label: { + Label(self.state.isClearingCache ? "Clearing…" : "Clear Disk Cache", systemImage: self.state.isClearingCache ? "hourglass" : "trash") + } + .buttonStyle(.borderedProminent) + .disabled(self.state.buttonIsDisabled) + + // Progress bar under the button + VStack(alignment: .leading, spacing: 6) { + switch self.state { + case .loading: + ProgressView("Loading Disk Usage") + case .loaded(let usage): + if usage.isEmpty { + Text("Cache is empty") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + Text("^[\(usage.count) cache files](inflect: true) (\(usage.formattedDiskUsage))") + .font(.footnote) + .foregroundStyle(.secondary) + } + case .clearing(let progress, let status): + ProgressView(value: progress) + .progressViewStyle(.linear) + .tint(.accentColor) + .opacity(progress > 0 ? 1 : 0) + + Text(status) + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Spacer() + Text("\(Int(progress * 100))%") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + case .error(let error): + Text(error.localizedDescription) + } + + } + }.task(self.fetchDiskCacheUsage) + } + } + + private func fetchDiskCacheUsage() async { + do { + let usage = try await cache.diskUsage() + await MainActor.run { + self.state = .loaded(usage: usage) + } + } catch { + await MainActor.run { + self.state = .error(error) + } + } + } + + // Simulated async cache clearing with progress updates. + private func clearDiskCache() async { + self.state = .clearing(progress: 0, result: "") + + do { + try await cache.removeAll { count, total in + let progress: Double + + if count > 0 && total > 0 { + progress = Double(count) / Double(total) + } else { + progress = 0 + } + + await MainActor.run { + withAnimation { + self.state = .clearing(progress: progress, result: "Working") + } + } + } + + await MainActor.run { + withAnimation { + self.state = .clearing(progress: 1.0, result: "Complete") + } + } + } catch { + await MainActor.run { + withAnimation { + self.state = .error(error) + } + } + } + } +} + +#Preview { + EmptyDiskCacheView() +} diff --git a/Modules/Sources/Support/UI/ErrorView.swift b/Modules/Sources/Support/UI/ErrorView.swift index 9bc58d8bf5c3..47c1dc69afeb 100644 --- a/Modules/Sources/Support/UI/ErrorView.swift +++ b/Modules/Sources/Support/UI/ErrorView.swift @@ -1,12 +1,12 @@ import SwiftUI -struct ErrorView: View { +public struct ErrorView: View { let title: String let message: String let systemImage: String let retryAction: (() -> Void)? - init( + public init( title: String = "Something went wrong", message: String = "Please try again later", systemImage: String = "exclamationmark.triangle.fill", @@ -18,7 +18,7 @@ struct ErrorView: View { self.retryAction = retryAction } - var body: some View { + public var body: some View { VStack(spacing: 16) { // Error icon Image(systemName: systemImage) diff --git a/Modules/Sources/Support/UI/LoadingLatestContentView.swift b/Modules/Sources/Support/UI/LoadingLatestContentView.swift new file mode 100644 index 000000000000..b738532aef44 --- /dev/null +++ b/Modules/Sources/Support/UI/LoadingLatestContentView.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct LoadingLatestContentView: View { + @State private var isVisible: Bool = false + + var body: some View { + ZStack { + // The toast container + HStack(spacing: 12) { + ProgressView() + .progressViewStyle(.circular) + + Text("Loading latest content…") + .font(.callout) + .foregroundStyle(.primary) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(.secondary.opacity(0.15)) + ) + .shadow(color: .black.opacity(0.15), radius: 10, x: 0, y: 4) + .opacity(isVisible ? 1 : 0) + .offset(y: isVisible ? 0 : 12) + .accessibilityElement(children: .combine) + .accessibilityLabel("Loading latest content") + .accessibilityAddTraits(.isStaticText) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, 24) + .onAppear { + withAnimation(.spring(response: 0.4, dampingFraction: 0.9)) { + isVisible = true + } + } + } +} + +#Preview { + NavigationStack { + List { + ForEach(0..<12) { i in + Text("Row \(i)") + } + } + .navigationTitle("Demo") + } + .overlay(alignment: .bottom) { + LoadingLatestContentView() + } +} diff --git a/Modules/Sources/Support/UI/ProfileView.swift b/Modules/Sources/Support/UI/ProfileView.swift index 17f0626d8f47..6de778de7f96 100644 --- a/Modules/Sources/Support/UI/ProfileView.swift +++ b/Modules/Sources/Support/UI/ProfileView.swift @@ -44,33 +44,35 @@ public struct ProfileView: 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 + Group { + if let avatarImage { + avatarImage .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) + } else if let avatarImageUrl { + CachedAsyncImage(url: avatarImageUrl) { image in + image + .resizable() + .scaledToFill() + .frame(width: 60, height: 60) + .clipShape(Circle()) + } placeholder: { + ProgressView() + }.frame(width: 60, height: 60) + } else { + ZStack { + Circle() + .fill(Color.secondary.opacity(0.2)) + .frame(width: 60, height: 60) - Image(systemName: "person.fill") - .font(.system(size: 30)) - .foregroundColor(.secondary) + Image(systemName: "person.fill") + .font(.system(size: 30)) + .foregroundColor(.secondary) + } } - } + }.frame(width: 60, height: 60) // User details VStack(alignment: .leading, spacing: 4) { diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift index 06f0799d270b..e72f02be5f21 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift @@ -4,6 +4,7 @@ public struct SupportConversationListView: View { enum ViewState { case loading + case partiallyLoaded([ConversationSummary]) case loaded([ConversationSummary]) case error(Error) } @@ -28,7 +29,8 @@ public struct SupportConversationListView: View { switch self.state { case .loading: ProgressView(Localization.loadingConversations) - case .loaded(let conversations): self.conversationsList(conversations) + case .partiallyLoaded(let conversations), .loaded(let conversations): + self.conversationsList(conversations) case .error(let error): ErrorView( title: Localization.errorLoadingSupportConversations, @@ -49,12 +51,17 @@ public struct SupportConversationListView: View { } } .sheet(isPresented: self.$isComposingNewMessage, content: { - NavigationView { + NavigationStack { SupportForm(supportIdentity: self.currentUser) }.environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller }) + .overlay(content: { + if case .partiallyLoaded = state { + LoadingLatestContentView() + } + }) .task(self.loadConversations) - .refreshable(action: self.loadConversations) + .refreshable(action: self.reloadConversations) } @ViewBuilder @@ -79,7 +86,29 @@ public struct SupportConversationListView: View { private func loadConversations() async { do { - let conversations = try await dataProvider.loadSupportConversations() + let fetch = try await dataProvider.loadSupportConversations() + + if let cachedResults = try await fetch.cachedResult() { + await MainActor.run { + self.state = .partiallyLoaded(cachedResults) + } + } + + let fetchedResults = try await fetch.fetchedResult() + + await MainActor.run { + self.state = .loaded(fetchedResults) + } + } catch { + await MainActor.run { + self.state = .error(error) + } + } + } + + private func reloadConversations() async { + do { + let conversations = try await dataProvider.loadSupportConversations().fetchedResult() await MainActor.run { self.state = .loaded(conversations) @@ -131,7 +160,7 @@ struct EmailRowView: View { } #Preview { - NavigationView { + NavigationStack { SupportConversationListView( currentUser: SupportDataProvider.supportUser ) diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift index 1929abcb898e..b0f8feda4a5b 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift @@ -4,6 +4,7 @@ public struct SupportConversationView: View { enum ViewState { case loading + case partiallyLoaded(Conversation) case loaded(Conversation) case error(Error) } @@ -22,9 +23,15 @@ public struct SupportConversationView: View { private let currentUser: SupportUser private var canReply: Bool { + // Don't enable the new conversation button if the user isn't eligible for it + guard currentUser.permissions.contains(.createSupportRequest) else { + return false + } + if case .loaded = state { return true } + return false } @@ -42,6 +49,7 @@ public struct SupportConversationView: View { switch self.state { case .loading: ProgressView(Localization.loadingMessages) + case .partiallyLoaded(let conversation): self.conversationView(conversation) case .loaded(let conversation): self.conversationView(conversation) case .error(let error): ErrorView( @@ -62,9 +70,14 @@ public struct SupportConversationView: View { .disabled(!canReply) } } + .overlay(content: { + if case .partiallyLoaded = state { + LoadingLatestContentView() + } + }) .sheet(isPresented: $isReplying) { if case .loaded(let conversation) = state { - NavigationView { + NavigationStack { SupportConversationReplyView( conversation: conversation, currentUser: currentUser, @@ -79,7 +92,13 @@ public struct SupportConversationView: View { } } .task(self.loadConversation) - .refreshable(action: self.loadConversation) + .refreshable(action: self.reloadConversation) + .onAppear { + debugPrint("💬 onAppear – detail") + } + .onDisappear { + debugPrint("💬 onDisappear – detail") + } } @ViewBuilder @@ -176,12 +195,51 @@ public struct SupportConversationView: View { private func loadConversation() async { do { let conversationId = self.conversationSummary.id - let conversation = try await self.dataProvider.loadSupportConversation(id: conversationId) - self.state = .loaded(conversation) + + let fetch = try await self.dataProvider.loadSupportConversation(id: conversationId) + + if let cached = try await fetch.cachedResult() { + debugPrint("💬 Finished fetching cached conversations") + + await MainActor.run { + self.state = .partiallyLoaded(cached) + } + } + + if Task.isCancelled { + preconditionFailure("need to handle cancellation!") + } + + let conversation = try await fetch.fetchedResult() + debugPrint("💬 Finished fetching cached conversations") + + await MainActor.run { + self.state = .loaded(conversation) + } } catch { self.state = .error(error) } } + + private func reloadConversation() async { + guard case .loaded(let conversation) = state else { + return + } + + do { + await MainActor.run { + self.state = .partiallyLoaded(conversation) + } + + let conversation = try await self.dataProvider.loadSupportConversation(id: conversation.id).fetchedResult() + + self.state = .loaded(conversation) + } catch { + await MainActor.run { + self.state = .error(error) + } + } + } } struct MessageRowView: View { @@ -254,14 +312,10 @@ struct AttachmentListView: View { } #Preview { - NavigationView { + NavigationStack { SupportConversationView( conversation: SupportDataProvider.supportConversationSummaries.first!, - currentUser: SupportUser( - userId: 1, - username: "john_doe", - email: "john@example.com" - ) + currentUser: SupportDataProvider.supportUser ) } .environmentObject(SupportDataProvider.testing) diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift index 96518f0da20d..92cc7997cf07 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift @@ -375,7 +375,7 @@ private extension SupportFormArea { // MARK: - Previews #Preview { - NavigationView { + NavigationStack { SupportForm( supportIdentity: SupportDataProvider.supportUser, applicationLogs: [SupportDataProvider.applicationLog] diff --git a/Modules/Sources/WordPressCore/CachedAndFetchedResult.swift b/Modules/Sources/WordPressCore/CachedAndFetchedResult.swift new file mode 100644 index 000000000000..062c74295296 --- /dev/null +++ b/Modules/Sources/WordPressCore/CachedAndFetchedResult.swift @@ -0,0 +1,97 @@ +import Foundation + +public protocol CachedAndFetchedResult: Sendable { + associatedtype T + + var cachedResult: @Sendable () async throws -> T? { get } + var fetchedResult: @Sendable () async throws -> T { get } +} + +/// A type that isn't actually cached (like Preview data providers) +public struct UncachedResult: CachedAndFetchedResult { + public let cachedResult: @Sendable () async throws -> T? + public let fetchedResult: @Sendable () async throws -> T + + public init( + fetchedResult: @Sendable @escaping () async throws -> T + ) { + self.cachedResult = { nil } + self.fetchedResult = fetchedResult + } +} + +/// Represents a double-returning promise – initially for a cached result that may be empty, and eventually for an expensive fetched result (usually from a server). +/// +/// This variant uses the `Caches` directory on-disk as its backing store +/// +public struct DiskCachedAndFetchedResult: CachedAndFetchedResult where T: Codable & Sendable { + public var cachedResult: @Sendable () async throws -> T? { + return self.readFromCache + } + + public var fetchedResult: @Sendable () async throws -> T { + return self.fetchAndCache + } + + private let userProvidedFetchBlock: @Sendable () async throws -> T + + private let cacheKey: String + + public init( + fetchedResult: @escaping @Sendable () async throws -> T, + cacheKey: String + ) { + self.userProvidedFetchBlock = fetchedResult + self.cacheKey = cacheKey + } + + public func fetchAndCache() async throws -> T { + let result = try await userProvidedFetchBlock() + try await DiskCache().store(result, forKey: self.cacheKey) + return result + } + + // We can ignore decoding failures here because the data format may change over time. Treating it as a cache + // miss is preferable to returning an error because the cache will simply be updated on the next remote fetch. + private func readFromCache() async throws -> T? { + try await DiskCache().read(T.self, forKey: self.cacheKey) + } +} + +public struct UserDefaultsCachedAndFetchedResult: CachedAndFetchedResult where T: Codable & Sendable { + public var cachedResult: @Sendable () async throws -> T? { + return self.readFromCache + } + + public var fetchedResult: @Sendable () async throws -> T { + return self.fetchAndCache + } + + private let userProvidedFetchBlock: @Sendable () async throws -> T + + private let cacheKey: String + + public init( + fetchedResult: @escaping @Sendable () async throws -> T, + cacheKey: String + ) { + self.userProvidedFetchBlock = fetchedResult + self.cacheKey = cacheKey + } + + public func fetchAndCache() async throws -> T { + let result = try await userProvidedFetchBlock() + try UserDefaults.standard.setValue(PropertyListEncoder().encode(result), forKey: self.cacheKey) + return result + } + + private func readFromCache() async throws -> T? { + guard let data = UserDefaults.standard.data(forKey: self.cacheKey) else { + return nil + } + + // We can ignore decoding failures here because the data format may change over time. Treating it as a cache + // miss is preferable to returning an error because the cache will simply be updated on the next remote fetch. + return try? PropertyListDecoder().decode(T.self, from: data) + } +} diff --git a/Modules/Sources/WordPressCore/DiskCache.swift b/Modules/Sources/WordPressCore/DiskCache.swift new file mode 100644 index 000000000000..f6a33779456d --- /dev/null +++ b/Modules/Sources/WordPressCore/DiskCache.swift @@ -0,0 +1,91 @@ +import Foundation + +/// A super-basic on-disk cache for `Codable` objects. +/// +public actor DiskCache { + + public struct DiskCacheUsage: Sendable, Equatable { + public let count: Int + let totalSize: Int64 + + public var diskUsage: Measurement { + Measurement(value: Double(totalSize), unit: .bytes) + } + + public var formattedDiskUsage: String { + return diskUsage.formatted(.byteCount(style: .file, allowedUnits: [.mb, .gb], spellsOutZero: true)) + } + + public var isEmpty: Bool { + count == 0 + } + } + + private let cacheRoot: URL = URL.cachesDirectory + + public init() {} + + public func read(_ type: T.Type, forKey key: String) throws -> T? where T: Decodable { + let path = self.path(forKey: key) + + guard FileManager.default.fileExists(at: path) else { + return nil + } + + let data = try Data(contentsOf: path) + + // We can ignore decoding failures here because the data format may change over time. Treating it as a cache + // miss is preferable to returning an error because the cache will simply be updated on the next remote fetch. + return try? JSONDecoder().decode(T.self, from: data) + } + + public func store(_ value: T, forKey key: String) throws where T: Encodable { + let data = try JSONEncoder().encode(value) + try data.write(to: self.path(forKey: key)) + } + + public func remove(key: String) throws { + let path = self.path(forKey: key) + guard FileManager.default.fileExists(at: path) else { + return + } + try FileManager.default.removeItem(at: self.path(forKey: key)) + } + + public func removeAll(progress: (@Sendable (Int, Int) async throws -> Void)? = nil) async throws { + let files = try await fetchCacheEntries() + + let count = files.count + + try await progress?(0, count) + + for file in files.enumerated() { + try FileManager.default.removeItem(at: file.element) + try await progress?(file.offset + 1, count) + } + } + + // The number of entries stored in this cache + public func count() async throws -> Int { + try await fetchCacheEntries().count + } + + public func diskUsage() async throws -> DiskCacheUsage { + let files = try await fetchCacheEntries() + + return DiskCacheUsage( + count: files.count, + totalSize: files.reduce(into: Int64(0)) { $0 += $1.fileSize ?? 0 } + ) + } + + private func fetchCacheEntries() async throws -> [URL] { + try FileManager.default + .contentsOfDirectory(at: cacheRoot, includingPropertiesForKeys: [.fileSizeKey]) + .filter { $0.lastPathComponent.hasSuffix(".cache.json") } + } + + private func path(forKey key: String) -> URL { + cacheRoot.appendingPathComponent("\(key).cache.json") + } +} diff --git a/Modules/Sources/WordPressCore/WordPressClient.swift b/Modules/Sources/WordPressCore/WordPressClient.swift index c7880f33fba5..23d31f4628f8 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -10,5 +10,4 @@ public actor WordPressClient { self.api = api self.rootUrl = rootUrl.url() } - } diff --git a/WordPress/Classes/Utility/AccountHelper.swift b/WordPress/Classes/Utility/AccountHelper.swift index 292e608a8d7d..1a968ef95ce4 100644 --- a/WordPress/Classes/Utility/AccountHelper.swift +++ b/WordPress/Classes/Utility/AccountHelper.swift @@ -1,4 +1,5 @@ import Foundation +import WordPressCore import WordPressData /// Encapsulates Account-Y Helpers @@ -112,6 +113,9 @@ import WordPressData do { // Delete all cached block editor settings try await BlockEditorCache.shared.deleteAll() + + // Delete everything in the disk cache + try await DiskCache().removeAll() } catch { debugPrint("Unable to delete all block editor settings: \(error)") } diff --git a/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift index c78fbbe35656..3d146d82d935 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift @@ -5,96 +5,199 @@ import WebKit struct RootSupportView: View { - @EnvironmentObject - var dataProvider: SupportDataProvider + enum ViewState { + case loading + case partiallyLoaded(user: SupportUser?) + case loaded(user: SupportUser?) + case error(Error) - @State - var dataLoadingError: Error? = nil + var isLoading: Bool { + guard case .loading = self else { + return false + } - @State - var userIdentity: SupportUser? = nil + return true + } + } + + @EnvironmentObject + var dataProvider: SupportDataProvider @State - var userIsEligibleForSupport: Bool = false + private var state: ViewState = .loading var body: some View { + VStack { + switch self.state { + case .loading: + ProgressView("Loading Support Profile") + case .partiallyLoaded(user: let currentUser), .loaded(let currentUser): + listView(identity: currentUser) + case .error(let error): + ErrorView( + title: "Unable to load support", + message: error.localizedDescription + ) + } + } + .task(self.loadIdentity) + .refreshable(action: self.reloadIdentity) + .navigationTitle("Support") + } + + @ViewBuilder + private func listView(identity: SupportUser?) -> some View { List { - Section("Support Profile") { - if let identity = self.userIdentity { + if let identity { + Section("Support Profile") { 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") - } + .listRowBackground(Color(.secondarySystemGroupedBackground)) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) } } 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" - ) - } + communitySupportLink + if let identity { + botSupportLink(for: identity) + humanSupportLink(for: identity) } } 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" - ) - } + applicationLogLink + diagnosticsLink + } + } + .listStyle(.insetGrouped) + .listRowBackground(Color(.secondarySystemGroupedBackground)) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } - NavigationLink { - Text("Site Status Report") - } label: { - SubtitledListViewItem( - title: "System Status Report", - subtitle: "Various system information about your site" - ) - } + @ViewBuilder + private var communitySupportLink: some View { + NavigationLink { + let url = URL(string: "https://apps.wordpress.com/support/")! + WebKitView(configuration: WebViewControllerConfiguration(url: url)) + } label: { + SupportAreaRow( + imageName: "book.pages", + title: "Help Center", + detail: "Documentation and tutorials to help you get started." + ) + } + } + + @ViewBuilder + private func botSupportLink(for identity: SupportUser) -> some View { + NavigationLink { + ConversationListView(currentUser: identity) + .environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller + } label: { + SupportAreaRow( + imageName: "bubble.left.and.text.bubble.right", + title: "Ask the bots", + detail: "Get quick answers to common questions." + ) + } + } + + @ViewBuilder + private func humanSupportLink(for identity: SupportUser) -> some View { + NavigationLink { + SupportConversationListView(currentUser: identity) + .environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller + } label: { + SupportAreaRow( + imageName: "envelope.badge", + title: "Ask the Happiness Engineers", + detail: "For your tough questions. We'll reply via email." + ) + } + } + + @ViewBuilder + private var applicationLogLink: some View { + NavigationLink { + ActivityLogListView() + .environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller + } label: { + SupportAreaRow( + imageName: "wrench.and.screwdriver", + title: "Application Logs", + detail: "Find out what the app is doing under the hood." + ) + } + } + + @ViewBuilder + private var diagnosticsLink: some View { + NavigationLink { + DiagnosticsView() + } label: { + SupportAreaRow( + imageName: "doc.text.magnifyingglass", + title: "System Status Report", + detail: "Tools to help diagnose issues" + ) + } + } + + @Sendable private func loadIdentity() async { + + do { + let result = try await self.dataProvider.loadSupportIdentity() + + // Don't treat a `nil` value as a cache miss – they might not be logged into WP.com + let cachedIdentity = try await result.cachedResult() + await MainActor.run { + self.state = .partiallyLoaded(user: cachedIdentity) + } + + // If we fail to fetch the user's identity, we'll assume they're logged out + let fetchedIdentity = try? await result.fetchedResult() + + await MainActor.run { + self.state = .loaded(user: fetchedIdentity) + } + } catch { + await MainActor.run { + self.state = .error(error) } } - .navigationTitle("Support") - .task { - do { - self.userIdentity = try await self.dataProvider.loadSupportIdentity() - } catch { - debugPrint(error.localizedDescription) - self.dataLoadingError = error + } + + @Sendable private func reloadIdentity() async { + do { + let fetchedIdentity = try await self.dataProvider.loadSupportIdentity().fetchedResult() + await MainActor.run { + self.state = .loaded(user: fetchedIdentity) + } + } catch { + await MainActor.run { + self.state = .error(error) + } + } + } +} + +struct SupportAreaRow: View { + + let imageName: String + let title: String + let detail: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: imageName) + .frame(width: 24, height: 24) + .foregroundColor(.accentColor) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.headline) + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) } } } diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index 09fd225a3196..1aca246350f5 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -4,6 +4,7 @@ import Support import SwiftUI import WordPressAPI import WordPressAPIInternal // Needed for `SupportUserIdentity` +import WordPressCore import WordPressData import WordPressShared import CocoaLumberjack @@ -20,7 +21,7 @@ extension SupportDataProvider { ), supportConversationDataProvider: WpSupportConversationDataProvider( wpcomClient: WordPressDotComClient()), - delegate: nil + delegate: WpSupportDelegate() ) } @@ -43,6 +44,16 @@ actor WpLogDataProvider: ApplicationLogDataProvider { } } +class WpSupportDelegate: NSObject, SupportDelegate { + func userDid(_ action: Support.SupportFormAction) { + // TODO: Handle metrics + } + + func userDid(_ action: Support.DiagnosticAction, progress: (Support.DiagnosticActionStatus) -> Void) { + // TODO: Handle user actions + } +} + actor WpBotConversationDataProvider: BotConversationDataProvider { private let botId = "jetpack-chat-mobile" @@ -55,29 +66,33 @@ actor WpBotConversationDataProvider: BotConversationDataProvider { self.wpcomClient = wpcomClient } - func loadBotConversations() async throws -> [Support.BotConversation] { - try await self.wpcomClient - .api - .supportBots - .getBotConverationList(botId: self.botId) - .data - .map { $0.asSupportConversation() } + func loadBotConversations() async throws -> any CachedAndFetchedResult<[Support.BotConversation]> { + return DiskCachedAndFetchedResult(fetchedResult: { + try await self.wpcomClient + .api + .supportBots + .getBotConverationList(botId: self.botId) + .data + .map { $0.asSupportConversation() } + }, cacheKey: "bot-conversation-list") } - 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 loadBotConversation(id: UInt64) async throws -> any CachedAndFetchedResult { + return DiskCachedAndFetchedResult(fetchedResult: { + 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() + }, cacheKey: "bot-conversation-\(id)") } func delete(conversationIds: [UInt64]) async throws { @@ -87,7 +102,7 @@ actor WpBotConversationDataProvider: BotConversationDataProvider { 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 + return try await loadBotConversation(id: conversation.id).fetchedResult() } else { return try await createConversation(message: message) } @@ -136,20 +151,19 @@ actor WpBotConversationDataProvider: BotConversationDataProvider { 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 - } + func fetchCurrentSupportUser() async throws -> any CachedAndFetchedResult { + return DiskCachedAndFetchedResult(fetchedResult: { + async let user = try await self.wpcomClient.api.me.get().data.asSupportIdentity() + async let eligibility = try await self.wpcomClient.api.supportEligibility.getSupportEligibility().data - let user = try await self.wpcomClient.api.me.get().data.asSupportIdentity() - cachedCurrentSupportUser = user - return user + let supportUser = try await user.applyingSupportEligibility(eligibility) + return supportUser + }, cacheKey: "current-support-user") } } @@ -161,20 +175,24 @@ actor WpSupportConversationDataProvider: SupportConversationDataProvider { self.wpcomClient = wpcomClient } - func loadSupportConversations() async throws -> [ConversationSummary] { - try await self.wpcomClient.api - .supportTickets - .getSupportConversationList() - .data - .map { $0.asConversationSummary() } + func loadSupportConversations() async throws -> any CachedAndFetchedResult<[ConversationSummary]> { + return DiskCachedAndFetchedResult(fetchedResult: { + try await self.wpcomClient.api + .supportTickets + .getSupportConversationList() + .data + .map { $0.asConversationSummary() } + }, cacheKey: "support-conversation-list") } - func loadSupportConversation(id: UInt64) async throws -> Conversation { - try await self.wpcomClient.api - .supportTickets - .getSupportConversation(conversationId: id) - .data - .asConversation() + func loadSupportConversation(id: UInt64) async throws -> any CachedAndFetchedResult { + return DiskCachedAndFetchedResult(fetchedResult: { + try await self.wpcomClient.api + .supportTickets + .getSupportConversation(conversationId: id) + .data + .asConversation() + }, cacheKey: "support-conversation-\(id)") } func createSupportConversation( @@ -207,11 +225,13 @@ actor WpSupportConversationDataProvider: SupportConversationDataProvider { attachments: attachments.map { $0.path() } ) - return try await self.wpcomClient.api + let conversation = try await self.wpcomClient.api .supportTickets .addMessageToSupportConversation(conversationId: id, params: params) .data .asConversation() + + return conversation } } @@ -223,7 +243,7 @@ extension WpComUserInfo { userId: self.id, username: self.displayName, email: self.email, - avatarUrl: self.getAvatarUrl(), + avatarUrl: self.getAvatarUrl() ) } @@ -236,6 +256,24 @@ extension WpComUserInfo { } } +extension SupportUser { + func applyingSupportEligibility(_ eligiblity: SupportEligibility) -> SupportUser { + var permissions = [SupportUserPermission]() + + if eligiblity.isUserEligible { + permissions = [.createSupportRequest, .createChatConversation] + } + + return SupportUser( + userId: self.userId, + username: self.username, + email: self.email, + permissions: permissions, + avatarUrl: self.avatarUrl, + ) + } +} + extension WordPressAPIInternal.BotConversationSummary { func asSupportConversation() -> Support.BotConversation { var summary = self.lastMessage.content From ff8bfcc6a57bb58b5fd69955c22c369ceff17110 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 17 Oct 2025 22:37:39 -0600 Subject: [PATCH 03/10] Fix up loading progress view --- .../ConversationListView.swift | 16 +- .../Bot Conversations/ConversationView.swift | 16 +- .../Support/UI/LoadingLatestContentView.swift | 53 ------- .../Support/UI/OverlayProgressView.swift | 141 ++++++++++++++++++ .../SupportConversationListView.swift | 33 +++- .../SupportConversationView.swift | 22 ++- 6 files changed, 205 insertions(+), 76 deletions(-) delete mode 100644 Modules/Sources/Support/UI/LoadingLatestContentView.swift create mode 100644 Modules/Sources/Support/UI/OverlayProgressView.swift diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift index 3c48e2319bf8..eb64c9fc9214 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift @@ -58,6 +58,14 @@ public struct ConversationListView: View { return .loaded(conversations, nil) } + + var isPartiallyLoaded: Bool { + guard case .partiallyLoaded = self else { + return false + } + + return true + } } enum ViewSubstate { @@ -109,11 +117,9 @@ public struct ConversationListView: View { .disabled(!currentUser.permissions.contains(.createChatConversation)) } } - .overlay(content: { - if case .partiallyLoaded = state { - LoadingLatestContentView() - } - }) + .overlay { + OverlayProgressView(shouldBeVisible: state.isPartiallyLoaded) + } .task(self.loadConversations) .refreshable(action: self.reloadConversations) } diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift index 02d77c6726e1..117307ae0445 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift @@ -183,6 +183,14 @@ public struct ConversationView: View { ) } } + + var isPartiallyLoaded: Bool { + guard case .partiallyLoaded = self else { + return false + } + + return true + } } enum ViewSubstate: Equatable { @@ -284,11 +292,9 @@ public struct ConversationView: View { ) } } - .overlay(content: { - if case .partiallyLoaded = state { - LoadingLatestContentView() - } - }) + .overlay { + OverlayProgressView(shouldBeVisible: state.isPartiallyLoaded) + } .task(self.loadExistingConversation) .refreshable(action: self.reloadConversation) } diff --git a/Modules/Sources/Support/UI/LoadingLatestContentView.swift b/Modules/Sources/Support/UI/LoadingLatestContentView.swift deleted file mode 100644 index b738532aef44..000000000000 --- a/Modules/Sources/Support/UI/LoadingLatestContentView.swift +++ /dev/null @@ -1,53 +0,0 @@ -import SwiftUI - -struct LoadingLatestContentView: View { - @State private var isVisible: Bool = false - - var body: some View { - ZStack { - // The toast container - HStack(spacing: 12) { - ProgressView() - .progressViewStyle(.circular) - - Text("Loading latest content…") - .font(.callout) - .foregroundStyle(.primary) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(.secondary.opacity(0.15)) - ) - .shadow(color: .black.opacity(0.15), radius: 10, x: 0, y: 4) - .opacity(isVisible ? 1 : 0) - .offset(y: isVisible ? 0 : 12) - .accessibilityElement(children: .combine) - .accessibilityLabel("Loading latest content") - .accessibilityAddTraits(.isStaticText) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.top, 24) - .onAppear { - withAnimation(.spring(response: 0.4, dampingFraction: 0.9)) { - isVisible = true - } - } - } -} - -#Preview { - NavigationStack { - List { - ForEach(0..<12) { i in - Text("Row \(i)") - } - } - .navigationTitle("Demo") - } - .overlay(alignment: .bottom) { - LoadingLatestContentView() - } -} diff --git a/Modules/Sources/Support/UI/OverlayProgressView.swift b/Modules/Sources/Support/UI/OverlayProgressView.swift new file mode 100644 index 000000000000..36e0f8d6e117 --- /dev/null +++ b/Modules/Sources/Support/UI/OverlayProgressView.swift @@ -0,0 +1,141 @@ +import SwiftUI + +struct OverlayProgressView: View { + + enum ViewState { + case mustBeHidden + case mustBeVisible + case inherit + } + + enum Style { + case toast + case horizontalBar + } + + let shouldBeVisible: Bool + private let minimumDisplayTime: Duration + private let style: Style + + @State + private var state: ViewState = .mustBeHidden // Start off hidden so the view animates in + + private var isVisible: Bool { + switch self.state { + case .mustBeHidden: false + case .mustBeVisible: true + case .inherit: shouldBeVisible + } + } + + init(shouldBeVisible: Bool, minimumDisplayTime: Duration = .seconds(3.8), style: Style = .horizontalBar) { + self.shouldBeVisible = shouldBeVisible + self.minimumDisplayTime = minimumDisplayTime + self.style = style + } + + var body: some View { + ZStack { + switch style { + case .toast: + toastView + case .horizontalBar: + horizontalBarView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: style == .toast ? .top : .bottom) + .padding(.top, style == .toast ? 24 : 0) + .padding(.bottom, style == .horizontalBar ? 0 : 0) + .onAppear { + withAnimation(.easeOut) { + self.state = .mustBeVisible + } + } + .task { + try? await Task.sleep(for: self.minimumDisplayTime) + await MainActor.run { + withAnimation(.easeOut) { + self.state = .inherit + } + } + } + } + + @ViewBuilder + private var toastView: some View { + // The toast container + HStack(spacing: 12) { + ProgressView() + .progressViewStyle(.circular) + + Text("Loading latest content") + .font(.callout) + .foregroundStyle(.primary) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(.secondary.opacity(0.15)) + ) + .shadow(color: .black.opacity(0.15), radius: 10, x: 0, y: 4) + .opacity(isVisible ? 1 : 0) + .offset(y: isVisible ? 0 : -12) + .accessibilityElement(children: .combine) + .accessibilityLabel("Loading latest content") + .accessibilityAddTraits(.isStaticText) + } + + @ViewBuilder + private var horizontalBarView: some View { + VStack(spacing: 0) { + Rectangle() + .fill(Color.accentColor) + .frame(height: 4) + .frame(maxWidth: .infinity) + .opacity(isVisible ? 1 : 0) + .scaleEffect(x: isVisible ? 1 : 0, y: 1, anchor: .leading) + .overlay( + Rectangle() + .fill(Color.accentColor.opacity(0.7)) + .scaleEffect(x: 0.3, y: 1) + .offset(x: isVisible ? UIScreen.main.bounds.width : -100) + .animation( + isVisible ? .easeInOut(duration: 1.2).repeatForever(autoreverses: false) : .default, + value: isVisible + ) + ) + .accessibilityLabel("Loading") + .accessibilityAddTraits(.updatesFrequently) + } + } +} + +#Preview("Toast Style") { + NavigationStack { + List { + ForEach(0..<12) { i in + Text("Row \(i)") + } + } + .navigationTitle("Demo") + } + .overlay(alignment: .top) { + OverlayProgressView(shouldBeVisible: true, style: .toast) + } +} + +#Preview("Horizontal Bar Style") { + NavigationStack { + List { + ForEach(0..<12) { i in + Text("Row \(i)") + } + } + .navigationTitle("Demo") + } + .overlay(alignment: .bottom) { + OverlayProgressView(shouldBeVisible: true, style: .horizontalBar) + } +} diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift index e72f02be5f21..98b4bd2e1490 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift @@ -2,11 +2,34 @@ import SwiftUI public struct SupportConversationListView: View { - enum ViewState { + enum ViewState: Equatable { case loading case partiallyLoaded([ConversationSummary]) case loaded([ConversationSummary]) case error(Error) + + static func == (lhs: ViewState, rhs: ViewState) -> Bool { + switch (lhs, rhs) { + case (.loading, .loading): + return true + case (.partiallyLoaded(let lhsConversations), .partiallyLoaded(let rhsConversations)): + return lhsConversations == rhsConversations + case (.loaded(let lhsConversations), .loaded(let rhsConversations)): + return lhsConversations == rhsConversations + case (.error, .error): + return true + default: + return false + } + } + + var isPartiallyLoaded: Bool { + guard case .partiallyLoaded = self else { + return false + } + + return true + } } @EnvironmentObject @@ -55,11 +78,9 @@ public struct SupportConversationListView: View { SupportForm(supportIdentity: self.currentUser) }.environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller }) - .overlay(content: { - if case .partiallyLoaded = state { - LoadingLatestContentView() - } - }) + .overlay { + OverlayProgressView(shouldBeVisible: self.state.isPartiallyLoaded) + } .task(self.loadConversations) .refreshable(action: self.reloadConversations) } diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift index b0f8feda4a5b..54e75e32d91b 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift @@ -7,6 +7,14 @@ public struct SupportConversationView: View { case partiallyLoaded(Conversation) case loaded(Conversation) case error(Error) + + var isPartiallyLoaded: Bool { + guard case .partiallyLoaded = self else { + return false + } + + return true + } } @EnvironmentObject @@ -49,8 +57,10 @@ public struct SupportConversationView: View { switch self.state { case .loading: ProgressView(Localization.loadingMessages) - case .partiallyLoaded(let conversation): self.conversationView(conversation) - case .loaded(let conversation): self.conversationView(conversation) + case .partiallyLoaded(let conversation): + self.conversationView(conversation) + case .loaded(let conversation): + self.conversationView(conversation) case .error(let error): ErrorView( title: Localization.unableToDisplayConversation, @@ -70,11 +80,9 @@ public struct SupportConversationView: View { .disabled(!canReply) } } - .overlay(content: { - if case .partiallyLoaded = state { - LoadingLatestContentView() - } - }) + .overlay { + OverlayProgressView(shouldBeVisible: self.state.isPartiallyLoaded) + } .sheet(isPresented: $isReplying) { if case .loaded(let conversation) = state { NavigationStack { From edd009c5b646af20559d63e4fc8940f3a2debdc9 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 21 Oct 2025 11:17:07 -0600 Subject: [PATCH 04/10] No async required to make `CachedAndFetchedResult` --- .../Support/InternalDataProvider.swift | 10 +++---- .../Sources/Support/SupportDataProvider.swift | 26 +++++++++---------- .../ActivityLogListView.swift | 3 +-- .../ConversationListView.swift | 4 --- .../SupportConversationListView.swift | 2 +- .../SupportConversationView.swift | 10 +------ .../Support Conversations/SupportForm.swift | 2 +- .../NewSupport/SupportDataProvider.swift | 14 +++++----- 8 files changed, 29 insertions(+), 42 deletions(-) diff --git a/Modules/Sources/Support/InternalDataProvider.swift b/Modules/Sources/Support/InternalDataProvider.swift index 116fc03b8a0c..a5aa146aec6e 100644 --- a/Modules/Sources/Support/InternalDataProvider.swift +++ b/Modules/Sources/Support/InternalDataProvider.swift @@ -273,13 +273,13 @@ actor InternalBotConversationDataProvider: BotConversationDataProvider { await SupportDataProvider.supportUser } - func loadBotConversations() async throws -> any CachedAndFetchedResult<[BotConversation]> { + nonisolated func loadBotConversations() throws -> any CachedAndFetchedResult<[BotConversation]> { UncachedResult { [await SupportDataProvider.botConversation] } } - func loadBotConversation(id: UInt64) async throws -> any CachedAndFetchedResult { + nonisolated func loadBotConversation(id: UInt64) throws -> any CachedAndFetchedResult { UncachedResult { if id == 5678 { return await SupportDataProvider.conversationReferredToHuman @@ -315,7 +315,7 @@ actor InternalBotConversationDataProvider: BotConversationDataProvider { } actor InternalUserDataProvider: CurrentUserDataProvider { - func fetchCurrentSupportUser() async throws -> any CachedAndFetchedResult { + nonisolated func fetchCurrentSupportUser() throws -> any CachedAndFetchedResult { UncachedResult { await SupportDataProvider.supportUser } @@ -325,13 +325,13 @@ actor InternalUserDataProvider: CurrentUserDataProvider { actor InternalSupportConversationDataProvider: SupportConversationDataProvider { private var conversations: [UInt64: Conversation] = [:] - func loadSupportConversations() async throws -> any CachedAndFetchedResult<[ConversationSummary]> { + nonisolated func loadSupportConversations() throws -> any CachedAndFetchedResult<[ConversationSummary]> { UncachedResult { return await SupportDataProvider.supportConversationSummaries } } - func loadSupportConversation(id: UInt64) async throws -> any CachedAndFetchedResult { + nonisolated func loadSupportConversation(id: UInt64) throws -> any CachedAndFetchedResult { UncachedResult { let conversation = await SupportDataProvider.supportConversation await self.cache(conversation) diff --git a/Modules/Sources/Support/SupportDataProvider.swift b/Modules/Sources/Support/SupportDataProvider.swift index 2a172ad1bba0..c1447edc1a36 100644 --- a/Modules/Sources/Support/SupportDataProvider.swift +++ b/Modules/Sources/Support/SupportDataProvider.swift @@ -49,17 +49,17 @@ public final class SupportDataProvider: ObservableObject, Sendable { } // Support Bots Data Source - public func loadSupportIdentity() async throws -> any CachedAndFetchedResult { - try await self.userDataProvider.fetchCurrentSupportUser() + public func loadSupportIdentity() throws -> any CachedAndFetchedResult { + try self.userDataProvider.fetchCurrentSupportUser() } // Bot Conversation Data Source public func loadConversations() async throws -> any CachedAndFetchedResult<[BotConversation]> { - try await self.botConversationDataProvider.loadBotConversations() + try self.botConversationDataProvider.loadBotConversations() } public func loadConversation(id: UInt64) async throws -> any CachedAndFetchedResult { - try await self.botConversationDataProvider.loadBotConversation(id: id) + try self.botConversationDataProvider.loadBotConversation(id: id) } public func delete(conversationIds: [UInt64]) async throws { @@ -71,12 +71,12 @@ public final class SupportDataProvider: ObservableObject, Sendable { } // Support Conversations Data Source - public func loadSupportConversations() async throws -> any CachedAndFetchedResult<[ConversationSummary]> { - try await self.supportConversationDataProvider.loadSupportConversations() + public func loadSupportConversations() throws -> any CachedAndFetchedResult<[ConversationSummary]> { + try self.supportConversationDataProvider.loadSupportConversations() } - public func loadSupportConversation(id: UInt64) async throws -> any CachedAndFetchedResult { - try await self.supportConversationDataProvider.loadSupportConversation(id: id) + public func loadSupportConversation(id: UInt64) throws -> any CachedAndFetchedResult { + try self.supportConversationDataProvider.loadSupportConversation(id: id) } public func replyToSupportConversation( @@ -171,7 +171,7 @@ public enum SupportUserPermission: Sendable, Codable { } public protocol CurrentUserDataProvider: Actor { - func fetchCurrentSupportUser() async throws -> any CachedAndFetchedResult + nonisolated func fetchCurrentSupportUser() throws -> any CachedAndFetchedResult } public protocol ApplicationLogDataProvider: Actor { @@ -194,16 +194,16 @@ public extension ApplicationLogDataProvider { } public protocol BotConversationDataProvider: Actor { - func loadBotConversations() async throws -> any CachedAndFetchedResult<[BotConversation]> - func loadBotConversation(id: UInt64) async throws -> any CachedAndFetchedResult + nonisolated func loadBotConversations() throws -> any CachedAndFetchedResult<[BotConversation]> + nonisolated func loadBotConversation(id: UInt64) throws -> any CachedAndFetchedResult func sendMessage(message: String, in conversation: BotConversation?) async throws -> BotConversation func delete(conversationIds: [UInt64]) async throws } public protocol SupportConversationDataProvider: Actor { - func loadSupportConversations() async throws -> any CachedAndFetchedResult<[ConversationSummary]> - func loadSupportConversation(id: UInt64) async throws -> any CachedAndFetchedResult + nonisolated func loadSupportConversations() throws -> any CachedAndFetchedResult<[ConversationSummary]> + nonisolated func loadSupportConversation(id: UInt64) throws -> any CachedAndFetchedResult func replyToSupportConversation( id: UInt64, diff --git a/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift b/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift index b0dfe3692814..dda85246fc75 100644 --- a/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift +++ b/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift @@ -165,7 +165,6 @@ public struct ActivityLogListView: View { #Preview { NavigationStack { - ActivityLogListView() .environmentObject(SupportDataProvider.testing) - + ActivityLogListView().environmentObject(SupportDataProvider.testing) } } diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift index eb64c9fc9214..1efe901b2751 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift @@ -149,8 +149,6 @@ public struct ConversationListView: View { let fetch = try await dataProvider.loadConversations() if let cachedConversations = try await fetch.cachedResult() { - debugPrint("💬 Finished fetching cached conversations") - await MainActor.run { self.state = .partiallyLoaded(cachedConversations) } @@ -158,8 +156,6 @@ public struct ConversationListView: View { let fetchedConversations = try await fetch.fetchedResult() - debugPrint("💬 Finished fetching conversations") - await MainActor.run { self.state = .loaded(fetchedConversations, .none) } diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift index 98b4bd2e1490..94b92709ba50 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift @@ -107,7 +107,7 @@ public struct SupportConversationListView: View { private func loadConversations() async { do { - let fetch = try await dataProvider.loadSupportConversations() + let fetch = try dataProvider.loadSupportConversations() if let cachedResults = try await fetch.cachedResult() { await MainActor.run { diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift index 54e75e32d91b..1e1c87a4077c 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift @@ -204,23 +204,15 @@ public struct SupportConversationView: View { do { let conversationId = self.conversationSummary.id - let fetch = try await self.dataProvider.loadSupportConversation(id: conversationId) + let fetch = try self.dataProvider.loadSupportConversation(id: conversationId) if let cached = try await fetch.cachedResult() { - debugPrint("💬 Finished fetching cached conversations") - await MainActor.run { self.state = .partiallyLoaded(cached) } } - if Task.isCancelled { - preconditionFailure("need to handle cancellation!") - } - let conversation = try await fetch.fetchedResult() - debugPrint("💬 Finished fetching cached conversations") - await MainActor.run { self.state = .loaded(conversation) } diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift index 92cc7997cf07..6a7be9f8e00c 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift @@ -269,7 +269,7 @@ private extension SupportForm { Task { do { - let conversation = try await self.dataProvider.createSupportConversation( + _ = try await self.dataProvider.createSupportConversation( subject: self.subject, message: self.getText(), user: self.supportIdentity, diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index 1aca246350f5..f82e905e8d41 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -66,8 +66,8 @@ actor WpBotConversationDataProvider: BotConversationDataProvider { self.wpcomClient = wpcomClient } - func loadBotConversations() async throws -> any CachedAndFetchedResult<[Support.BotConversation]> { - return DiskCachedAndFetchedResult(fetchedResult: { + nonisolated func loadBotConversations() throws -> any CachedAndFetchedResult<[Support.BotConversation]> { + DiskCachedAndFetchedResult(fetchedResult: { try await self.wpcomClient .api .supportBots @@ -77,7 +77,7 @@ actor WpBotConversationDataProvider: BotConversationDataProvider { }, cacheKey: "bot-conversation-list") } - func loadBotConversation(id: UInt64) async throws -> any CachedAndFetchedResult { + nonisolated func loadBotConversation(id: UInt64) throws -> any CachedAndFetchedResult { return DiskCachedAndFetchedResult(fetchedResult: { let params = GetBotConversationParams( pageNumber: 1, @@ -156,8 +156,8 @@ actor WpCurrentUserDataProvider: CurrentUserDataProvider { self.wpcomClient = wpcomClient } - func fetchCurrentSupportUser() async throws -> any CachedAndFetchedResult { - return DiskCachedAndFetchedResult(fetchedResult: { + nonisolated func fetchCurrentSupportUser() throws -> any CachedAndFetchedResult { + DiskCachedAndFetchedResult(fetchedResult: { async let user = try await self.wpcomClient.api.me.get().data.asSupportIdentity() async let eligibility = try await self.wpcomClient.api.supportEligibility.getSupportEligibility().data @@ -175,7 +175,7 @@ actor WpSupportConversationDataProvider: SupportConversationDataProvider { self.wpcomClient = wpcomClient } - func loadSupportConversations() async throws -> any CachedAndFetchedResult<[ConversationSummary]> { + nonisolated func loadSupportConversations() throws -> any CachedAndFetchedResult<[ConversationSummary]> { return DiskCachedAndFetchedResult(fetchedResult: { try await self.wpcomClient.api .supportTickets @@ -185,7 +185,7 @@ actor WpSupportConversationDataProvider: SupportConversationDataProvider { }, cacheKey: "support-conversation-list") } - func loadSupportConversation(id: UInt64) async throws -> any CachedAndFetchedResult { + nonisolated func loadSupportConversation(id: UInt64) throws -> any CachedAndFetchedResult { return DiskCachedAndFetchedResult(fetchedResult: { try await self.wpcomClient.api .supportTickets From 3da9f2c593e3bacea94c4a3e201eeb0334588c6e Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 21 Oct 2025 11:23:11 -0600 Subject: [PATCH 05/10] Remove unused diagnostic action delegate --- Modules/Sources/Support/SupportDataProvider.swift | 15 --------------- .../NewSupport/SupportDataProvider.swift | 4 ---- 2 files changed, 19 deletions(-) diff --git a/Modules/Sources/Support/SupportDataProvider.swift b/Modules/Sources/Support/SupportDataProvider.swift index c1447edc1a36..2068e459a040 100644 --- a/Modules/Sources/Support/SupportDataProvider.swift +++ b/Modules/Sources/Support/SupportDataProvider.swift @@ -5,16 +5,6 @@ public enum SupportFormAction { case viewSupportForm } -public enum DiagnosticAction { - case clearDiskCache -} - -public enum DiagnosticActionStatus { - case running(progress: Float) - case success - case error(Error) -} - @MainActor public final class SupportDataProvider: ObservableObject, Sendable { @@ -44,10 +34,6 @@ public final class SupportDataProvider: ObservableObject, Sendable { self.supportDelegate?.userDid(action) } - public func userDid(_ action: DiagnosticAction, progress: @escaping (DiagnosticActionStatus) -> Void) { - self.supportDelegate?.userDid(action, progress: progress) - } - // Support Bots Data Source public func loadSupportIdentity() throws -> any CachedAndFetchedResult { try self.userDataProvider.fetchCurrentSupportUser() @@ -162,7 +148,6 @@ extension SupportFormDataProvider { public protocol SupportDelegate: NSObject { func userDid(_ action: SupportFormAction) - func userDid(_ action: DiagnosticAction, progress: (DiagnosticActionStatus) -> Void) } public enum SupportUserPermission: Sendable, Codable { diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index f82e905e8d41..40538c9d7f49 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -48,10 +48,6 @@ class WpSupportDelegate: NSObject, SupportDelegate { func userDid(_ action: Support.SupportFormAction) { // TODO: Handle metrics } - - func userDid(_ action: Support.DiagnosticAction, progress: (Support.DiagnosticActionStatus) -> Void) { - // TODO: Handle user actions - } } actor WpBotConversationDataProvider: BotConversationDataProvider { From e6ef3561d36f101f190719f86de9985acc2046fa Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:09:11 -0600 Subject: [PATCH 06/10] Add telemetry --- .../Sources/Support/SupportDataProvider.swift | 80 ++++++++++++++--- .../ActivityLogDetailView.swift | 22 +++-- .../ActivityLogListView.swift | 3 + .../ConversationListView.swift | 3 + .../Bot Conversations/ConversationView.swift | 8 +- .../UI/Diagnostics/DiagnosticsView.swift | 7 +- .../UI/Diagnostics/EmptyDiskCacheView.swift | 14 ++- .../ScreenshotPicker.swift | 4 +- .../SupportConversationListView.swift | 3 + .../SupportConversationView.swift | 9 +- Modules/Sources/WordPressCore/DiskCache.swift | 12 +-- .../Utility/Analytics/WPAnalyticsEvent.swift | 19 ++++ .../NewSupport/SupportDataProvider.swift | 90 ++++++++++++++++++- 13 files changed, 232 insertions(+), 42 deletions(-) diff --git a/Modules/Sources/Support/SupportDataProvider.swift b/Modules/Sources/Support/SupportDataProvider.swift index 2068e459a040..84f003d6401e 100644 --- a/Modules/Sources/Support/SupportDataProvider.swift +++ b/Modules/Sources/Support/SupportDataProvider.swift @@ -2,7 +2,27 @@ import Foundation import WordPressCore public enum SupportFormAction { - case viewSupportForm + case viewApplicationLogList + case viewApplicationLog(String) + case deleteApplicationLogs([String]) + case deleteAllApplicationLogs + + case viewSupportBotConversationList + case startSupportBotConversation + case viewSupportBotConversation(conversationId: UInt64) + case replyToSupportBotMessage(conversationId: UInt64) + case failToCreateBotConversation(Error) + case failToReplyToBotConversation(Error) + + case viewSupportTicketList + case viewSupportTicket(ticketId: UInt64) + case createSupportTicket + case replyToSupportTicket(ticketId: UInt64) + case failToCreateSupportTicket(Error) + case failToReplyToSupportTicket(Error) + + case viewDiagnostics + case emptyDiskCache(bytesSaved: Int64) } @MainActor @@ -53,7 +73,23 @@ public final class SupportDataProvider: ObservableObject, Sendable { } public func sendMessage(message: String, in conversation: BotConversation? = nil) async throws -> BotConversation { - try await self.botConversationDataProvider.sendMessage(message: message, in: conversation) + if let conversation { + self.userDid(.replyToSupportBotMessage(conversationId: conversation.id)) + } else { + self.userDid(.startSupportBotConversation) + } + + do { + return try await self.botConversationDataProvider.sendMessage(message: message, in: conversation) + } catch { + if conversation != nil { + self.userDid(.failToCreateBotConversation(error)) + } else { + self.userDid(.failToReplyToBotConversation(error)) + } + + throw error + } } // Support Conversations Data Source @@ -71,12 +107,19 @@ public final class SupportDataProvider: ObservableObject, Sendable { user: SupportUser, attachments: [URL] ) async throws -> Conversation { - try await self.supportConversationDataProvider.replyToSupportConversation( - id: id, - message: message, - user: user, - attachments: attachments - ) + self.userDid(.replyToSupportTicket(ticketId: id)) + + do { + return try await self.supportConversationDataProvider.replyToSupportConversation( + id: id, + message: message, + user: user, + attachments: attachments + ) + } catch { + self.userDid(.failToReplyToSupportTicket(error)) + throw error + } } public func createSupportConversation( @@ -85,12 +128,19 @@ public final class SupportDataProvider: ObservableObject, Sendable { user: SupportUser, attachments: [URL] ) async throws -> Conversation { - try await self.supportConversationDataProvider.createSupportConversation( - subject: subject, - message: message, - user: user, - attachments: attachments - ) + self.userDid(.createSupportTicket) + + do { + return try await self.supportConversationDataProvider.createSupportConversation( + subject: subject, + message: message, + user: user, + attachments: attachments + ) + } catch { + self.userDid(.failToCreateSupportTicket(error)) + throw error + } } // Application Logs @@ -103,10 +153,12 @@ public final class SupportDataProvider: ObservableObject, Sendable { } public func deleteApplicationLogs(in list: [ApplicationLog]) async throws { + self.userDid(.deleteApplicationLogs(list.map({ $0.id }))) try await self.applicationLogProvider.deleteApplicationLogs(in: list) } public func deleteAllApplicationLogs() async throws { + self.userDid(.deleteAllApplicationLogs) try await self.applicationLogProvider.deleteAllApplicationLogs() } } diff --git a/Modules/Sources/Support/UI/Application Logs/ActivityLogDetailView.swift b/Modules/Sources/Support/UI/Application Logs/ActivityLogDetailView.swift index 0ca081815d4b..4e9890c94c88 100644 --- a/Modules/Sources/Support/UI/Application Logs/ActivityLogDetailView.swift +++ b/Modules/Sources/Support/UI/Application Logs/ActivityLogDetailView.swift @@ -8,7 +8,7 @@ struct ActivityLogDetailView: View { enum ViewState: Equatable { case loading - case loaded(String, Bool) + case loaded(String, isSharing: Bool) case error(Error) static func == (lhs: ActivityLogDetailView.ViewState, rhs: ActivityLogDetailView.ViewState) -> Bool { @@ -26,7 +26,7 @@ struct ActivityLogDetailView: View { private var state: ViewState = .loading @State - private var isSharing: Bool = false + private var isDisplayingShareSheet: Bool = false @State private var sharingIsDisabled: Bool = true @@ -53,26 +53,29 @@ struct ActivityLogDetailView: View { .disabled(self.sharingIsDisabled) } } - .sheet(isPresented: self.$isSharing, onDismiss: { + .sheet(isPresented: self.$isDisplayingShareSheet, onDismiss: { guard case .loaded(let content, _) = self.state else { return } - self.state = .loaded(content, false) + self.state = .loaded(content, isSharing: false) }, content: { ActivityLogSharingView(applicationLog: applicationLog) { AnyView(erasing: Text("TODO: A new support request with the application log attached")) } .presentationDetents([.medium]) }) + .onAppear { + self.dataProvider.userDid(.viewApplicationLog(self.applicationLog.id)) + } .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 + self.isDisplayingShareSheet = isSharing } else { - self.isSharing = false + self.isDisplayingShareSheet = false self.sharingIsDisabled = true } } @@ -108,7 +111,7 @@ struct ActivityLogDetailView: View { do { let content = try await self.dataProvider.readApplicationLog(applicationLog) - self.state = .loaded(content, false) + self.state = .loaded(content, isSharing: false) } catch { self.state = .error(error) } @@ -119,13 +122,14 @@ struct ActivityLogDetailView: View { return } - state = .loaded(content, true) + state = .loaded(content, isSharing: true) } } #Preview { NavigationStack { ActivityLogDetailView( - applicationLog: SupportDataProvider.applicationLog ).environmentObject(SupportDataProvider.testing) + 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 index dda85246fc75..865fd82ebda1 100644 --- a/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift +++ b/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift @@ -68,6 +68,9 @@ public struct ActivityLogListView: View { }, message: { Text("You won't be able to get them back.") }) + .onAppear { + self.dataProvider.userDid(.viewApplicationLogList) + } .refreshable { await self.loadLogFiles() } diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift index 1efe901b2751..8affee3f884c 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift @@ -120,6 +120,9 @@ public struct ConversationListView: View { .overlay { OverlayProgressView(shouldBeVisible: state.isPartiallyLoaded) } + .onAppear { + self.dataProvider.userDid(.viewSupportBotConversationList) + } .task(self.loadConversations) .refreshable(action: self.reloadConversations) } diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift index 117307ae0445..887bd2801987 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift @@ -295,6 +295,13 @@ public struct ConversationView: View { .overlay { OverlayProgressView(shouldBeVisible: state.isPartiallyLoaded) } + .onAppear { + if let conversationId { + self.dataProvider.userDid(.viewSupportBotConversation(conversationId: conversationId)) + } else { + self.dataProvider.userDid(.startSupportBotConversation) + } + } .task(self.loadExistingConversation) .refreshable(action: self.reloadConversation) } @@ -447,7 +454,6 @@ public struct ConversationView: View { private func sendMessage(_ message: String) { self.state = self.state.transitioningToSendingMessage(message: message, task: Task { do { - let thinkingTask = Task.delayedAndRunOnMainActor(for: .seconds(1.5)) { self.state = self.state.transitioningToThinking() } diff --git a/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift b/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift index 3e59e3ba4a64..8a6eac6cf227 100644 --- a/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift +++ b/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift @@ -3,6 +3,9 @@ import WordPressCore public struct DiagnosticsView: View { + @EnvironmentObject + private var dataProvider: SupportDataProvider + public init() {} public var body: some View { @@ -17,7 +20,9 @@ public struct DiagnosticsView: View { } .navigationTitle("Diagnostics") .background(.background) - + .onAppear { + dataProvider.userDid(.viewDiagnostics) + } } } diff --git a/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift b/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift index c1ac345d32d7..5b328ce02762 100644 --- a/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift +++ b/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift @@ -3,6 +3,9 @@ import WordPressCore struct EmptyDiskCacheView: View { + @EnvironmentObject + private var dataProvider: SupportDataProvider + enum ViewState: Equatable { case loading case loaded(usage: DiskCache.DiskCacheUsage) @@ -77,7 +80,7 @@ struct EmptyDiskCacheView: View { .font(.footnote) .foregroundStyle(.secondary) } else { - Text("^[\(usage.count) cache files](inflect: true) (\(usage.formattedDiskUsage))") + Text("^[\(usage.fileCount) cache files](inflect: true) (\(usage.formattedDiskUsage))") .font(.footnote) .foregroundStyle(.secondary) } @@ -102,7 +105,8 @@ struct EmptyDiskCacheView: View { } } - }.task(self.fetchDiskCacheUsage) + } + .task(self.fetchDiskCacheUsage) } } @@ -121,6 +125,12 @@ struct EmptyDiskCacheView: View { // Simulated async cache clearing with progress updates. private func clearDiskCache() async { + guard case .loaded(let usage) = state else { + return + } + + self.dataProvider.userDid(.emptyDiskCache(bytesSaved: usage.byteCount)) + self.state = .clearing(progress: 0, result: "") do { diff --git a/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift b/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift index b52843ea6922..b8647e411656 100644 --- a/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift +++ b/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift @@ -67,10 +67,10 @@ struct ScreenshotPicker: View { selection: $selectedPhotos, maxSelectionCount: maxScreenshots, matching: .images - ) { + ) { [imageCount = attachedImages.count] in HStack { Image(systemName: "camera.fill") - Text(attachedImages.isEmpty ? Localization.addScreenshots : Localization.addMoreScreenshots) + Text(imageCount == 0 ? Localization.addScreenshots : Localization.addMoreScreenshots) } .frame(maxWidth: .infinity) .padding() diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift index 94b92709ba50..3d27142c7305 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift @@ -81,6 +81,9 @@ public struct SupportConversationListView: View { .overlay { OverlayProgressView(shouldBeVisible: self.state.isPartiallyLoaded) } + .onAppear { + self.dataProvider.userDid(.viewSupportTicketList) + } .task(self.loadConversations) .refreshable(action: self.reloadConversations) } diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift index 1e1c87a4077c..086008865068 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift @@ -99,14 +99,11 @@ public struct SupportConversationView: View { .environmentObject(dataProvider) } } - .task(self.loadConversation) - .refreshable(action: self.reloadConversation) .onAppear { - debugPrint("💬 onAppear – detail") - } - .onDisappear { - debugPrint("💬 onDisappear – detail") + self.dataProvider.userDid(.viewSupportTicket(ticketId: conversationSummary.id)) } + .task(self.loadConversation) + .refreshable(action: self.reloadConversation) } @ViewBuilder diff --git a/Modules/Sources/WordPressCore/DiskCache.swift b/Modules/Sources/WordPressCore/DiskCache.swift index f6a33779456d..61ddab5d23b0 100644 --- a/Modules/Sources/WordPressCore/DiskCache.swift +++ b/Modules/Sources/WordPressCore/DiskCache.swift @@ -5,11 +5,11 @@ import Foundation public actor DiskCache { public struct DiskCacheUsage: Sendable, Equatable { - public let count: Int - let totalSize: Int64 + public let fileCount: Int + public let byteCount: Int64 public var diskUsage: Measurement { - Measurement(value: Double(totalSize), unit: .bytes) + Measurement(value: Double(byteCount), unit: .bytes) } public var formattedDiskUsage: String { @@ -17,7 +17,7 @@ public actor DiskCache { } public var isEmpty: Bool { - count == 0 + fileCount == 0 } } @@ -74,8 +74,8 @@ public actor DiskCache { let files = try await fetchCacheEntries() return DiskCacheUsage( - count: files.count, - totalSize: files.reduce(into: Int64(0)) { $0 += $1.fileSize ?? 0 } + fileCount: files.count, + byteCount: files.reduce(into: Int64(0)) { $0 += $1.fileSize ?? 0 } ) } diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift index f656aec72ee2..77ef8acc5bd7 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift @@ -549,6 +549,17 @@ import WordPressShared case supportChatbotTicketFailure case supportChatbotEnded + case supportChatbot + + // Support Tickets + case supportTickets + + // Application Logs + case applicationLog + + // Application Diagnostics + case diagnostics + // Jetpack plugin connection to user's WP.com account case jetpackPluginConnectUserAccountStarted case jetpackPluginConnectUserAccountFailed @@ -1618,6 +1629,12 @@ import WordPressShared return "support_migration_faq_tapped" case .supportMigrationFAQCardViewed: return "support_migration_faq_viewed" + case .supportTickets: + return "jetpack_mobile_support_tickets" + case .applicationLog: + return "jetpack_mobile_application_log" + case .diagnostics: + return "jetpack_mobile_diagnostics" // Chatbot Support case .supportChatbotStarted: @@ -1630,6 +1647,8 @@ import WordPressShared return "support_chatbot_ticket_failure" case .supportChatbotEnded: return "support_chatbot_ended" + case .supportChatbot: + return "jetpack_mobile_support_chatbot" // Jetpack plugin connection to user's WP.com account case .jetpackPluginConnectUserAccountStarted: diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index 40538c9d7f49..d2b5992bb112 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -46,7 +46,95 @@ actor WpLogDataProvider: ApplicationLogDataProvider { class WpSupportDelegate: NSObject, SupportDelegate { func userDid(_ action: Support.SupportFormAction) { - // TODO: Handle metrics + + switch action { + case .viewApplicationLogList: + WPAnalytics.track(.applicationLog, properties: [ + "subaction": "view-list" + ]) + case .viewApplicationLog(let id): + WPAnalytics.track(.applicationLog, properties: [ + "subaction": "view-single", + "log_id": id + ]) + case .deleteApplicationLogs(let ids): + for id in ids { + WPAnalytics.track(.applicationLog, properties: [ + "subaction": "delete-log", + "log_id": id + ]) + } + case .deleteAllApplicationLogs: + WPAnalytics.track(.applicationLog, properties: [ + "subaction": "delete-all" + ]) + + case .viewSupportBotConversationList: + WPAnalytics.track(.supportChatbot, properties: [ + "subaction": "view-list" + ]) + case .startSupportBotConversation: + WPAnalytics.track(.supportChatbot, properties: [ + "subaction": "start-conversation" + ]) + case .failToCreateBotConversation(let error): + WPAnalytics.track(.supportChatbot, properties: [ + "subaction": "error-starting-conversation", + "error": error.localizedDescription + ]) + case .viewSupportBotConversation(let id): + WPAnalytics.track(.supportChatbot, properties: [ + "subaction": "view-conversation", + "conversation_id": id + ]) + case .replyToSupportBotMessage(let id): + WPAnalytics.track(.supportChatbot, properties: [ + "subaction": "reply-to-conversation", + "conversation_id": id + ]) + case .failToReplyToBotConversation(let error): + WPAnalytics.track(.supportChatbot, properties: [ + "subaction": "error-replying-to-conversation", + "error": error.localizedDescription + ]) + case .viewSupportTicketList: + WPAnalytics.track(.supportTickets, properties: [ + "subaction": "view-list" + ]) + case .viewSupportTicket(let id): + WPAnalytics.track(.supportTickets, properties: [ + "subaction": "view-ticket", + "ticket_id": id + ]) + case .createSupportTicket: + WPAnalytics.track(.supportTickets, properties: [ + "subaction": "create-ticket", + ]) + case .failToCreateSupportTicket(let error): + WPAnalytics.track(.supportTickets, properties: [ + "subaction": "error-creating-ticket", + "error": error.localizedDescription + ]) + case .replyToSupportTicket(let id): + WPAnalytics.track(.supportTickets, properties: [ + "subaction": "reply-to-ticket", + "ticket_id": id + ]) + case .failToReplyToSupportTicket(let error): + WPAnalytics.track(.supportTickets, properties: [ + "subaction": "error-replying-to-ticket", + "error": error.localizedDescription + ]) + case .viewDiagnostics: + WPAnalytics.track(.diagnostics, properties: [ + "subaction": "view-list" + ]) + case .emptyDiskCache(bytesSaved: let bytesSaved): + WPAnalytics.track(.diagnostics, properties: [ + "subaction": "empty-disk-cache", + "bytes-saved": bytesSaved + ]) + } } } From af3c476cc5e47bbe9b2d32cefd8b67fc2d44c2e0 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 21 Oct 2025 13:54:59 -0600 Subject: [PATCH 07/10] Remove TmpDebugMiddleware --- .../Networking/WordPressDotComClient.swift | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/WordPress/Classes/Networking/WordPressDotComClient.swift b/WordPress/Classes/Networking/WordPressDotComClient.swift index 4d40b7cc7bad..107b8981659f 100644 --- a/WordPress/Classes/Networking/WordPressDotComClient.swift +++ b/WordPress/Classes/Networking/WordPressDotComClient.swift @@ -14,7 +14,7 @@ actor WordPressDotComClient { let delegate = WpApiClientDelegate( authProvider: .dynamic(dynamicAuthenticationProvider: provider), requestExecutor: WpRequestExecutor(urlSession: session), - middlewarePipeline: WpApiMiddlewarePipeline(middlewares: [TmpDebugMiddleware()]), + middlewarePipeline: WpApiMiddlewarePipeline(middlewares: []), appNotifier: WpComNotifier() ) @@ -94,18 +94,3 @@ final class WpComNotifier: WpAppNotifier { 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 - } -} From 314b74dcb5245a0b9c7695e14c2ba1338a759cd4 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 21 Oct 2025 19:46:02 -0600 Subject: [PATCH 08/10] Fix diagnostics --- WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift index 3d146d82d935..c2565c03f56d 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift @@ -134,6 +134,7 @@ struct RootSupportView: View { private var diagnosticsLink: some View { NavigationLink { DiagnosticsView() + .environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller } label: { SupportAreaRow( imageName: "doc.text.magnifyingglass", @@ -146,7 +147,7 @@ struct RootSupportView: View { @Sendable private func loadIdentity() async { do { - let result = try await self.dataProvider.loadSupportIdentity() + let result = try self.dataProvider.loadSupportIdentity() // Don't treat a `nil` value as a cache miss – they might not be logged into WP.com let cachedIdentity = try await result.cachedResult() From 10bc23534403c29aa14f07f43af428779efb52d8 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 21 Oct 2025 19:46:57 -0600 Subject: [PATCH 09/10] Fix view title --- .../Support/UI/Application Logs/ActivityLogListView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift b/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift index 865fd82ebda1..09ec53ec1102 100644 --- a/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift +++ b/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift @@ -41,7 +41,7 @@ public struct ActivityLogListView: View { ) } } - .navigationTitle("Activity Logs") + .navigationTitle("Application Logs") .overlay { if case .loaded(_, let deletionState) = self.state { switch deletionState { From 44eb13cbacda129e678ee8f714be5a5ee39a216c Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 21 Oct 2025 19:49:52 -0600 Subject: [PATCH 10/10] Fix loading state --- .../Support/UI/OverlayProgressView.swift | 91 ++++--------------- 1 file changed, 16 insertions(+), 75 deletions(-) diff --git a/Modules/Sources/Support/UI/OverlayProgressView.swift b/Modules/Sources/Support/UI/OverlayProgressView.swift index 36e0f8d6e117..ebd1466ef6ee 100644 --- a/Modules/Sources/Support/UI/OverlayProgressView.swift +++ b/Modules/Sources/Support/UI/OverlayProgressView.swift @@ -8,14 +8,8 @@ struct OverlayProgressView: View { case inherit } - enum Style { - case toast - case horizontalBar - } - let shouldBeVisible: Bool private let minimumDisplayTime: Duration - private let style: Style @State private var state: ViewState = .mustBeHidden // Start off hidden so the view animates in @@ -28,42 +22,12 @@ struct OverlayProgressView: View { } } - init(shouldBeVisible: Bool, minimumDisplayTime: Duration = .seconds(3.8), style: Style = .horizontalBar) { + init(shouldBeVisible: Bool, minimumDisplayTime: Duration = .seconds(3.8)) { self.shouldBeVisible = shouldBeVisible self.minimumDisplayTime = minimumDisplayTime - self.style = style } var body: some View { - ZStack { - switch style { - case .toast: - toastView - case .horizontalBar: - horizontalBarView - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: style == .toast ? .top : .bottom) - .padding(.top, style == .toast ? 24 : 0) - .padding(.bottom, style == .horizontalBar ? 0 : 0) - .onAppear { - withAnimation(.easeOut) { - self.state = .mustBeVisible - } - } - .task { - try? await Task.sleep(for: self.minimumDisplayTime) - await MainActor.run { - withAnimation(.easeOut) { - self.state = .inherit - } - } - } - } - - @ViewBuilder - private var toastView: some View { - // The toast container HStack(spacing: 12) { ProgressView() .progressViewStyle(.circular) @@ -85,48 +49,25 @@ struct OverlayProgressView: View { .accessibilityElement(children: .combine) .accessibilityLabel("Loading latest content") .accessibilityAddTraits(.isStaticText) - } - - @ViewBuilder - private var horizontalBarView: some View { - VStack(spacing: 0) { - Rectangle() - .fill(Color.accentColor) - .frame(height: 4) - .frame(maxWidth: .infinity) - .opacity(isVisible ? 1 : 0) - .scaleEffect(x: isVisible ? 1 : 0, y: 1, anchor: .leading) - .overlay( - Rectangle() - .fill(Color.accentColor.opacity(0.7)) - .scaleEffect(x: 0.3, y: 1) - .offset(x: isVisible ? UIScreen.main.bounds.width : -100) - .animation( - isVisible ? .easeInOut(duration: 1.2).repeatForever(autoreverses: false) : .default, - value: isVisible - ) - ) - .accessibilityLabel("Loading") - .accessibilityAddTraits(.updatesFrequently) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, 24) + .onAppear { + withAnimation(.easeOut) { + self.state = .mustBeVisible + } } - } -} - -#Preview("Toast Style") { - NavigationStack { - List { - ForEach(0..<12) { i in - Text("Row \(i)") + .task { + try? await Task.sleep(for: self.minimumDisplayTime) + await MainActor.run { + withAnimation(.easeOut) { + self.state = .inherit + } } } - .navigationTitle("Demo") - } - .overlay(alignment: .top) { - OverlayProgressView(shouldBeVisible: true, style: .toast) } } -#Preview("Horizontal Bar Style") { +#Preview { NavigationStack { List { ForEach(0..<12) { i in @@ -135,7 +76,7 @@ struct OverlayProgressView: View { } .navigationTitle("Demo") } - .overlay(alignment: .bottom) { - OverlayProgressView(shouldBeVisible: true, style: .horizontalBar) + .overlay(alignment: .top) { + OverlayProgressView(shouldBeVisible: true) } }