diff --git a/Modules/Package.swift b/Modules/Package.swift index 38559cc14b49..aa5202185014 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -21,6 +21,8 @@ let package = Package( .library(name: "WordPressShared", targets: ["WordPressShared"]), .library(name: "WordPressUI", targets: ["WordPressUI"]), .library(name: "WordPressReader", targets: ["WordPressReader"]), + .library(name: "WordPressCore", targets: ["WordPressCore"]), + .library(name: "WordPressCoreProtocols", targets: ["WordPressCoreProtocols"]), ], dependencies: [ .package(url: "https://github.com/airbnb/lottie-ios", from: "4.4.0"), @@ -137,7 +139,7 @@ let package = Package( name: "Support", dependencies: [ "AsyncImageKit", - "WordPressCore", + "WordPressCoreProtocols", ] ), .target(name: "TextBundle"), @@ -152,10 +154,15 @@ let package = Package( ], swiftSettings: [.swiftLanguageMode(.v5)]), .target(name: "WordPressFlux", swiftSettings: [.swiftLanguageMode(.v5)]), .target(name: "WordPressCore", dependencies: [ + "WordPressCoreProtocols", "WordPressShared", - .product(name: "WordPressAPI", package: "wordpress-rs") + .product(name: "WordPressAPI", package: "wordpress-rs"), ] ), + .target(name: "WordPressCoreProtocols", dependencies: [ + // This package should never have dependencies – it exists to expose protocols implemented in WordPressCore + // to UI code, because `wordpress-rs` doesn't work nicely with previews. + ]), .target(name: "WordPressLegacy", dependencies: ["DesignSystem", "WordPressShared"]), .target(name: "WordPressSharedObjC", resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]), .target( @@ -177,6 +184,7 @@ let package = Package( "DesignSystem", "WordPressShared", "WordPressLegacy", + .product(name: "ColorStudio", package: "color-studio"), .product(name: "Reachability", package: "Reachability"), ], resources: [.process("Resources")], diff --git a/Modules/Sources/AsyncImageKit/Helpers/AnimagedImage.swift b/Modules/Sources/AsyncImageKit/Helpers/AnimatedImage.swift similarity index 100% rename from Modules/Sources/AsyncImageKit/Helpers/AnimagedImage.swift rename to Modules/Sources/AsyncImageKit/Helpers/AnimatedImage.swift diff --git a/Modules/Sources/AsyncImageKit/ImageDownloader.swift b/Modules/Sources/AsyncImageKit/ImageDownloader.swift index 70e1f25f3bf1..ea08a2cab203 100644 --- a/Modules/Sources/AsyncImageKit/ImageDownloader.swift +++ b/Modules/Sources/AsyncImageKit/ImageDownloader.swift @@ -1,4 +1,6 @@ import UIKit +import CryptoKit +import AVFoundation /// The system that downloads and caches images, and prepares them for display. @ImageDownloaderActor @@ -27,21 +29,43 @@ public final class ImageDownloader { self.cache = cache } - public func image(from url: URL, host: MediaHostProtocol? = nil, options: ImageRequestOptions = .init()) async throws -> UIImage { + public func image( + from url: URL, + host: MediaHostProtocol? = nil, + options: ImageRequestOptions = ImageRequestOptions() + ) async throws -> UIImage { try await image(for: ImageRequest(url: url, host: host, options: options)) } public func image(for request: ImageRequest) async throws -> UIImage { let options = request.options let key = makeKey(for: request.source.url, size: options.size) - if options.isMemoryCacheEnabled, let image = cache[key] { + + if let cachedImage = try self.fetch(key, options: request.options) { + return cachedImage + } + + if case .video(let url, let mediaHost) = request.source { + let asset: AVAsset = try await mediaHost?.authenticatedAsset(for: url) ?? AVURLAsset(url: url) + let generator = AVAssetImageGenerator(asset: asset) + + if let size = options.size { + generator.maximumSize = CGSize(width: size.width, height: size.height) + } + + let result = try await generator.image(at: .zero) + let image = UIImage(cgImage: result.image) + + try store(image, for: key, options: options) + return image } + let data = try await data(for: request) let image = try await ImageDecoder.makeImage(from: data, size: options.size.map(CGSize.init)) - if options.isMemoryCacheEnabled { - cache[key] = image - } + + try store(image, for: key, options: options) + return image } @@ -63,6 +87,8 @@ public final class ImageDownloader { return request case .urlRequest(let urlRequest): return urlRequest + case .video: + preconditionFailure("Cannot make URLRequest for video – use AVFoundation APIs instead") } } @@ -151,6 +177,53 @@ public final class ImageDownloader { throw ImageDownloaderError.unacceptableStatusCode(response.statusCode) } } + + // MARK: Manual caching + private func fetch(_ key: String, options: ImageRequestOptions) throws -> UIImage? { + + if options.isMemoryCacheEnabled, let image = cache[key] { + return image + } + + guard options.isDiskCacheEnabled else { + return nil + } + + let path = path(for: key) + + guard FileManager.default.fileExists(atPath: path.path()) else { + return nil + } + + let pngData = try Data(contentsOf: path) + return UIImage(data: pngData) + } + + private func store(_ image: UIImage, for key: String, options: ImageRequestOptions) throws { + + if options.isMemoryCacheEnabled { + cache[key] = image + } + + // If the image is immutable, store it in the disk cache "forever" + guard options.isDiskCacheEnabled, options.mutability == .immutable else { + return + } + + guard let data = image.pngData() else { + return + } + + let path = self.path(for: key) + + try FileManager.default.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: path) + } + + private func path(for key: String) -> URL { + let hash = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined() + return URL.cachesDirectory.appending(path: "image-cache").appending(path: hash) + } } @ImageDownloaderActor @@ -196,4 +269,5 @@ private extension URLSession { public protocol MediaHostProtocol: Sendable { @MainActor func authenticatedRequest(for url: URL) async throws -> URLRequest + @MainActor func authenticatedAsset(for url: URL) async throws -> AVURLAsset } diff --git a/Modules/Sources/AsyncImageKit/ImagePrefetcher.swift b/Modules/Sources/AsyncImageKit/ImagePrefetcher.swift index 7b807ec0dd99..5f6fcf093987 100644 --- a/Modules/Sources/AsyncImageKit/ImagePrefetcher.swift +++ b/Modules/Sources/AsyncImageKit/ImagePrefetcher.swift @@ -1,4 +1,4 @@ -import UIKit +import Foundation import Collections @ImageDownloaderActor diff --git a/Modules/Sources/AsyncImageKit/ImageRequest.swift b/Modules/Sources/AsyncImageKit/ImageRequest.swift index 9573ab0edbd2..04cd96ae504d 100644 --- a/Modules/Sources/AsyncImageKit/ImageRequest.swift +++ b/Modules/Sources/AsyncImageKit/ImageRequest.swift @@ -4,11 +4,21 @@ public final class ImageRequest: Sendable { public enum Source: Sendable { case url(URL, MediaHostProtocol?) case urlRequest(URLRequest) + case video(URL, MediaHostProtocol?) var url: URL? { switch self { case .url(let url, _): url case .urlRequest(let request): request.url + case .video(let url, _): url + } + } + + var host: MediaHostProtocol? { + switch self { + case .url(_, let host): host + case .urlRequest: nil + case .video(_, let host): host } } } @@ -25,6 +35,34 @@ public final class ImageRequest: Sendable { self.source = .urlRequest(urlRequest) self.options = options } + + public init(videoUrl: URL, host: MediaHostProtocol? = nil, options: ImageRequestOptions = ImageRequestOptions()) { + self.source = .video(videoUrl, host) + self.options = options + } +} + +/// Defines the mutability characteristics of a resource for caching purposes. +/// +/// This affects how aggressively the resource is cached: +/// - `.mutable`: Resources are cached in memory and URLSession's disk cache (with eviction policies) +/// - `.immutable`: Resources are additionally cached persistently on disk (never evicted automatically) +public enum ResourceMutability: Sendable { + /// Items that might change over time while keeping the same URL. + /// + /// These resources are cached in memory and URLSession's disk cache, but not persistently. + /// The cache may be evicted based on system policies. + /// + /// **Example**: Site Icons, Gravatars - the same URL might return different images as users update their profiles + case mutable + + /// Items that will never be modified after creation. + /// + /// These resources are cached persistently on disk in addition to in-memory caching. + /// Once downloaded, they remain cached indefinitely since the content at the URL will never change. + /// + /// **Example**: Support ticket attachments - these URLs point to immutable content + case immutable } public struct ImageRequestOptions: Hashable, Sendable { @@ -38,14 +76,25 @@ public struct ImageRequestOptions: Hashable, Sendable { /// with a relatively high disk capacity. By default, `true`. public var isDiskCacheEnabled = true + /// Indicates how this asset should be cached based on whether the content can change. + /// + /// Use `.mutable` (default) for resources that might change over time (like user avatars). + /// Use `.immutable` for resources that never change (like support attachments) to enable + /// persistent disk caching that survives app restarts and system cache evictions. + /// + /// - Note: Only applies when `isDiskCacheEnabled` is `true` + public let mutability: ResourceMutability + public init( size: ImageSize? = nil, isMemoryCacheEnabled: Bool = true, - isDiskCacheEnabled: Bool = true + isDiskCacheEnabled: Bool = true, + mutability: ResourceMutability = .mutable ) { self.size = size self.isMemoryCacheEnabled = isMemoryCacheEnabled self.isDiskCacheEnabled = isDiskCacheEnabled + self.mutability = mutability } } diff --git a/Modules/Sources/AsyncImageKit/Views/CachedAsyncImage.swift b/Modules/Sources/AsyncImageKit/Views/CachedAsyncImage.swift index d6ef77690540..50989b9e4aff 100644 --- a/Modules/Sources/AsyncImageKit/Views/CachedAsyncImage.swift +++ b/Modules/Sources/AsyncImageKit/Views/CachedAsyncImage.swift @@ -4,14 +4,14 @@ import SwiftUI /// It uses `ImageDownloader` to fetch and cache the images. public struct CachedAsyncImage: View where Content: View { @State private var phase: AsyncImagePhase = .empty - private let url: URL? + + private let request: ImageRequest? private let content: (AsyncImagePhase) -> Content private let imageDownloader: ImageDownloader - private let host: MediaHostProtocol? public var body: some View { content(phase) - .task(id: url) { await fetchImage() } + .task(id: request?.source.url) { await fetchImage() } } // MARK: - Initializers @@ -19,7 +19,9 @@ public struct CachedAsyncImage: View where Content: View { /// Initializes an image without any customization. /// Provides a plain color as placeholder public init(url: URL?) where Content == _ConditionalContent { - self.init(url: url) { phase in + let request = url == nil ? nil : ImageRequest(url: url!) + + self.init(request: request) { phase in if let image = phase.image { image } else { @@ -28,15 +30,83 @@ public struct CachedAsyncImage: View where Content: View { } } + public init( + url: URL?, + host: MediaHostProtocol? = nil, + imageDownloader: ImageDownloader = .shared, + mutability: ResourceMutability = .mutable, + @ViewBuilder content: @escaping (AsyncImagePhase) -> Content + ) { + if let url { + let request = ImageRequest(url: url, host: host, options: ImageRequestOptions( + mutability: mutability + )) + + self.init( + request: request, + imageDownloader: imageDownloader, + content: content + ) + } else { + self.init(request: nil, imageDownloader: imageDownloader, content: content) + } + } + /// Allows content customization and providing a placeholder that will be shown /// until the image download is finalized. public init( url: URL?, host: MediaHostProtocol? = nil, + imageDownloader: ImageDownloader = .shared, + mutability: ResourceMutability = .mutable, + @ViewBuilder content: @escaping (Image) -> I, + @ViewBuilder placeholder: @escaping () -> P + ) where Content == _ConditionalContent, I: View, P: View { + if let url { + let request = ImageRequest(url: url, host: host, options: ImageRequestOptions( + mutability: mutability + )) + + self.init( + request: request, + imageDownloader: imageDownloader, + content: content, + placeholder: placeholder + ) + } else { + self.init(request: nil, content: content, placeholder: placeholder) + } + } + + /// Allows content customization and providing a placeholder that will be shown + /// until the image download is finalized. + public init( + videoUrl: URL, + host: MediaHostProtocol? = nil, + imageDownloader: ImageDownloader = .shared, + mutability: ResourceMutability = .mutable, @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P ) where Content == _ConditionalContent, I: View, P: View { - self.init(url: url, host: host) { phase in + self.init( + request: ImageRequest(videoUrl: videoUrl, host: host, options: ImageRequestOptions( + mutability: mutability + )), + imageDownloader: imageDownloader, + content: content, + placeholder: placeholder + ) + } + + /// Allows content customization and providing a placeholder that will be shown + /// until the image download is finalized. + private init( + request: ImageRequest?, + imageDownloader: ImageDownloader = .shared, + @ViewBuilder content: @escaping (Image) -> I, + @ViewBuilder placeholder: @escaping () -> P + ) where Content == _ConditionalContent, I: View, P: View { + self.init(request: request, imageDownloader: imageDownloader) { phase in if let image = phase.image { content(image) } else { @@ -45,14 +115,12 @@ public struct CachedAsyncImage: View where Content: View { } } - public init( - url: URL?, - host: MediaHostProtocol? = nil, + private init( + request: ImageRequest?, imageDownloader: ImageDownloader = .shared, @ViewBuilder content: @escaping (AsyncImagePhase) -> Content ) { - self.url = url - self.host = host + self.request = request self.imageDownloader = imageDownloader self.content = content } @@ -61,19 +129,15 @@ public struct CachedAsyncImage: View where Content: View { private func fetchImage() async { do { - guard let url else { + guard let request, let url = request.source.url else { phase = .empty return } + if let image = imageDownloader.cachedImage(for: url) { phase = .success(Image(uiImage: image)) } else { - let image: UIImage - if let host { - image = try await imageDownloader.image(from: url, host: host) - } else { - image = try await imageDownloader.image(from: url) - } + let image = try await imageDownloader.image(for: request) phase = .success(Image(uiImage: image)) } } catch { @@ -81,3 +145,63 @@ public struct CachedAsyncImage: View where Content: View { } } } + +fileprivate let testURL = URL(string: "https://i0.wp.com/themes.svn.wordpress.org/twentytwentyfive/1.3/screenshot.png")! + +// This video is the preview because it's not just black for the first frame, and right now video previews only fetch the first frame +fileprivate let videoURL = URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4")! + +#Preview("Basic Image") { + CachedAsyncImage(url: testURL) +} + +#Preview("Image Sized to fit") { + CachedAsyncImage(url: testURL) { image in + image.resizable().scaledToFit() + } placeholder: { + Text("Loading") + } +} + +#Preview("Image that never loads") { + CachedAsyncImage(url: nil) { image in + Text("This shouldn't be visible") + } placeholder: { + ProgressView("Forever loading...") + } +} + +@available(iOS 18.0, *) +#Preview("Manual State Handling") { + + @Previewable let cases: [String: URL?] = [ + "Success": testURL, + "Failure": URL(string: "example://foo/bar"), + "Never": nil + ] + + TabView { + ForEach(cases.keys.sorted().reversed(), id: \.self) { key in + Tab(key, systemImage: "placeholdertext.fill") { + CachedAsyncImage(url: cases[key]!, host: nil) { phase in + switch phase { + case .success(let image): + image.resizable().aspectRatio(contentMode: .fit) + case .failure: + Color.red + default: + Color.gray + } + } + } + } + } +} + +#Preview("Video") { + CachedAsyncImage(videoUrl: videoURL) { image in + image.resizable().aspectRatio(contentMode: .fit) + } placeholder: { + Text("Loading") + } +} diff --git a/Modules/Sources/Support/Extensions/Foundation.swift b/Modules/Sources/Support/Extensions/Foundation.swift index a7e0fa8d3960..59daf9c6b2b5 100644 --- a/Modules/Sources/Support/Extensions/Foundation.swift +++ b/Modules/Sources/Support/Extensions/Foundation.swift @@ -5,6 +5,28 @@ extension Date { let calendar = Calendar.autoupdatingCurrent return calendar.isDateInToday(self) } + + var hasPast: Bool { + Date.now > self + } +} + +extension String { + func applyingNumericMorphology(for number: Int) -> String { + var attr = AttributedString(self) + var morphology = Morphology() + morphology.number = switch number { + case 0: .zero + case 1: .singular + case 2: .pluralTwo + case 3...7: .pluralFew + case 7...: .pluralMany + default: .plural + } + attr.inflect = InflectionRule(morphology: morphology) + + return attr.inflected().characters.reduce(into: "") { $0.append($1) } + } } extension AttributedString { @@ -93,4 +115,15 @@ extension Task where Failure == Error { return try await MainActor.run(body: operation) } } + + static func runForAtLeast( + _ duration: C.Instant.Duration, + operation: @escaping @Sendable () async throws -> Success, + clock: C = .continuous + ) async throws -> Success where C: Clock { + async let waitResult: () = try await clock.sleep(for: duration) + async let performTask = try await operation() + + return try await (waitResult, performTask).1 + } } diff --git a/Modules/Sources/Support/InternalDataProvider.swift b/Modules/Sources/Support/InternalDataProvider.swift index 8369fdcffd04..07f3747c6e8d 100644 --- a/Modules/Sources/Support/InternalDataProvider.swift +++ b/Modules/Sources/Support/InternalDataProvider.swift @@ -1,5 +1,7 @@ import Foundation -import WordPressCore +import AVFoundation +import AsyncImageKit +import WordPressCoreProtocols // This file is all module-internal and provides sample data for UI development @@ -8,7 +10,9 @@ extension SupportDataProvider { applicationLogProvider: InternalLogDataProvider(), botConversationDataProvider: InternalBotConversationDataProvider(), userDataProvider: InternalUserDataProvider(), - supportConversationDataProvider: InternalSupportConversationDataProvider() + supportConversationDataProvider: InternalSupportConversationDataProvider(), + diagnosticsDataProvider: InternalDiagnosticsDataProvider(), + mediaHost: InternalMediaHost() ) static let applicationLog = ApplicationLog(path: URL(filePath: #filePath), createdAt: Date(), modifiedAt: Date()) @@ -21,6 +25,7 @@ extension SupportDataProvider { static let botConversation = BotConversation( id: 1234, title: "App Crashing on Launch", + createdAt: Date().addingTimeInterval(-3600), // 1 hour ago messages: [ BotMessage( id: 1001, @@ -84,6 +89,7 @@ extension SupportDataProvider { BotConversation( id: 5678, title: "App Crashing on Launch", + createdAt: Date().addingTimeInterval(-60), // 1 minute ago messages: botConversation.messages + [ BotMessage( id: 1009, @@ -107,48 +113,56 @@ extension SupportDataProvider { id: 1, title: "Login Issues with Two-Factor Authentication", description: "I'm having trouble logging into my account. The two-factor authentication code isn't working properly and I keep getting locked out.", + status: .waitingForSupport, lastMessageSentAt: Date().addingTimeInterval(-300) // 5 minutes ago ), ConversationSummary( id: 2, title: "Billing Question - Duplicate Charges", description: "I noticed duplicate charges on my credit card statement for this month's subscription. Can you help me understand what happened?", + status: .waitingForUser, lastMessageSentAt: Date().addingTimeInterval(-3600) // 1 hour ago ), ConversationSummary( id: 3, title: "Feature Request: Dark Mode Support", description: "Would it be possible to add dark mode support to the mobile app? Many users in our team have been requesting this feature.", + status: .resolved, lastMessageSentAt: Date().addingTimeInterval(-86400) // 1 day ago ), ConversationSummary( id: 4, title: "Data Export Not Working", description: "I'm trying to export my data but the process keeps failing at 50%. Is there a known issue with large datasets?", + status: .resolved, lastMessageSentAt: Date().addingTimeInterval(-172800) // 2 days ago ), ConversationSummary( id: 5, title: "Account Migration Assistance", description: "I need help migrating my old account to the new system. I have several years of data that I don't want to lose.", + status: .resolved, lastMessageSentAt: Date().addingTimeInterval(-259200) // 3 days ago ), ConversationSummary( id: 6, title: "API Rate Limiting Questions", description: "Our application is hitting rate limits frequently. Can we discuss increasing our API quota or optimizing our usage patterns?", + status: .closed, lastMessageSentAt: Date().addingTimeInterval(-604800) // 1 week ago ), ConversationSummary( id: 7, title: "Security Concern - Suspicious Activity", description: "I received an email about suspicious activity on my account. I want to make sure my account is secure and review recent access logs.", + status: .closed, lastMessageSentAt: Date().addingTimeInterval(-1209600) // 2 weeks ago ), ConversationSummary( id: 8, title: "Integration Help with Webhook Setup", description: "I'm having trouble setting up webhooks for our CRM integration. The endpoints aren't receiving the expected payload format.", + status: .closed, lastMessageSentAt: Date().addingTimeInterval(-1814400) // 3 weeks ago ) ] @@ -158,6 +172,7 @@ extension SupportDataProvider { title: "Issue with app crashes", description: "The app keeps crashing when I try to upload photos. This has been happening for the past week and is very frustrating.", lastMessageSentAt: Date().addingTimeInterval(-2400), + status: .closed, messages: [ Message( id: 1, @@ -198,7 +213,15 @@ extension SupportDataProvider { createdAt: Date().addingTimeInterval(-1800), authorName: "Test User", authorIsUser: true, - attachments: [] + attachments: [ + Attachment( + id: 1234, + filename: "sample-1234.jpg", + contentType: "application/jpeg", + fileSize: 1234, + url: URL(string: "https://picsum.photos/seed/1/800/600")! + ) + ] ), Message( id: 6, @@ -321,6 +344,8 @@ actor InternalUserDataProvider: CurrentUserDataProvider { } actor InternalSupportConversationDataProvider: SupportConversationDataProvider { + let maximumUploadSize: UInt64 = 5_000_000 // 5MB + private var conversations: [UInt64: Conversation] = [:] nonisolated func loadSupportConversations() throws -> any CachedAndFetchedResult<[ConversationSummary]> { @@ -374,6 +399,7 @@ actor InternalSupportConversationDataProvider: SupportConversationDataProvider { title: subject, description: message, lastMessageSentAt: Date(), + status: .waitingForSupport, messages: [Message( id: 1234, content: message, @@ -389,3 +415,51 @@ actor InternalSupportConversationDataProvider: SupportConversationDataProvider { self.conversations[value.id] = value } } + +actor InternalDiagnosticsDataProvider: DiagnosticsDataProvider { + + private var didClear: Bool = false + + func fetchDiskCacheUsage() async throws -> WordPressCoreProtocols.DiskCacheUsage { + if didClear { + DiskCacheUsage(fileCount: 0, byteCount: 0) + } else { + DiskCacheUsage(fileCount: 64, byteCount: 623_423_562) + } + } + + func clearDiskCache(progress: @Sendable (CacheDeletionProgress) async throws -> Void) async throws { + let totalFiles = 12 + + // Initial progress (0%) + try await progress(CacheDeletionProgress(filesDeleted: 0, totalFileCount: totalFiles)) + + for i in 1...totalFiles { + // Pretend each file takes a short time to delete + try await Task.sleep(for: .milliseconds(150)) + + // Report incremental progress + try await progress(CacheDeletionProgress(filesDeleted: i, totalFileCount: totalFiles)) + } + + self.didClear = true + } +} + +actor InternalMediaHost: MediaHostProtocol { + func authenticatedRequest(for url: URL) async throws -> URLRequest { + if Bool.random() { + throw CocoaError(.coderInvalidValue) + } + + return URLRequest(url: url) + } + + func authenticatedAsset(for url: URL) async throws -> AVURLAsset { + if Bool.random() { + throw CocoaError(.coderInvalidValue) + } + + return AVURLAsset(url: url) + } +} diff --git a/Modules/Sources/Support/Localization.swift b/Modules/Sources/Support/Localization.swift index 48c63e21f72d..7b184af4a686 100644 --- a/Modules/Sources/Support/Localization.swift +++ b/Modules/Sources/Support/Localization.swift @@ -206,6 +206,11 @@ enum Localization { value: "Sending", comment: "Progress text while sending a message" ) + static let sendingMessage = NSLocalizedString( + "com.jetpack.support.sendingMessage", + value: "Sending Message", + comment: "Progress message shown while sending a message" + ) static let unableToSendMessage = NSLocalizedString( "com.jetpack.support.unableToSendMessage", value: "Unable to send Message", @@ -216,4 +221,311 @@ enum Localization { value: "Message Sent", comment: "Success message when reply is sent successfully" ) + static let discardChanges = NSLocalizedString( + "com.jetpack.support.discardChanges", + value: "Discard Changes", + comment: "Button to discard changes in a draft message" + ) + static let continueWriting = NSLocalizedString( + "com.jetpack.support.continueWriting", + value: "Continue Writing", + comment: "Button to continue editing a message" + ) + static let confirmCancelMessage = NSLocalizedString( + "com.jetpack.support.confirmCancelMessage", + value: "Are you sure you want to cancel this message? You'll lose any data you've entered", + comment: "Confirmation message when canceling a draft" + ) + static let confirmCancellation = NSLocalizedString( + "com.jetpack.support.confirmCancellation", + value: "Confirm Cancellation", + comment: "Title for alert confirming cancellation" + ) + static let emailNotice = NSLocalizedString( + "com.jetpack.support.emailNotice", + value: "We'll email you at this address.", + comment: "Notice explaining where support will send email responses" + ) + + // MARK: - DiagnosticsView.swift + + static let diagnosticsTitle = NSLocalizedString( + "com.jetpack.support.diagnosticsTitle", + value: "Diagnostics", + comment: "Navigation title for diagnostics screen" + ) + static let diagnosticsDescription = NSLocalizedString( + "com.jetpack.support.diagnosticsDescription", + value: "Run common maintenance and troubleshooting tasks.", + comment: "Description text for diagnostics screen" + ) + + // MARK: - EmptyDiskCacheView.swift + + static let clearDiskCache = NSLocalizedString( + "com.jetpack.support.clearDiskCache", + value: "Clear Disk Cache", + comment: "Button to clear disk cache" + ) + static let clearing = NSLocalizedString( + "com.jetpack.support.clearing", + value: "Clearing…", + comment: "Progress text while clearing cache" + ) + static let cacheIsEmpty = NSLocalizedString( + "com.jetpack.support.cacheIsEmpty", + value: "Cache is empty", + comment: "Message shown when cache has no files" + ) + static let cacheFiles = NSLocalizedString( + "com.jetpack.support.cacheFiles", + value: "%1$d cached files (%2$@)", + comment: "Format string for cache file count and size. %1$d is the number of files, %2$@ is the formatted size. The system will pluralize 'files' based on the number – please specify it as the largest plural value" + ) + static let clearDiskCacheDescription = NSLocalizedString( + "com.jetpack.support.clearDiskCacheDescription", + value: "Remove temporary files to free up space or resolve problems.", + comment: "Description explaining the purpose of clearing disk cache" + ) + static let loadingDiskUsage = NSLocalizedString( + "com.jetpack.support.loadingDiskUsage", + value: "Loading Disk Usage", + comment: "Progress message while loading disk usage information" + ) + static let working = NSLocalizedString( + "com.jetpack.support.working", + value: "Working", + comment: "Progress message shown during cache clearing operation" + ) + static let complete = NSLocalizedString( + "com.jetpack.support.complete", + value: "Complete", + comment: "Message shown when cache clearing is complete" + ) + + // MARK: - ActivityLogListView.swift + + static let applicationLogsTitle = NSLocalizedString( + "com.jetpack.support.applicationLogsTitle", + value: "Application Logs", + comment: "Navigation title for application logs screen" + ) + + // MARK: - ActivityLogDetailView.swift + + static let loadingLogContent = NSLocalizedString( + "com.jetpack.support.loadingLogContent", + value: "Loading log content...", + comment: "Progress message while loading application log content" + ) + static let confirmDeleteAllLogs = NSLocalizedString( + "com.jetpack.support.confirmDeleteAllLogs", + value: "Are you sure you want to delete all logs?", + comment: "Confirmation dialog title when deleting all logs" + ) + static let deleteAllLogs = NSLocalizedString( + "com.jetpack.support.deleteAllLogs", + value: "Delete all Logs", + comment: "Button to delete all log files" + ) + static let cannotRecoverLogs = NSLocalizedString( + "com.jetpack.support.cannotRecoverLogs", + value: "You won't be able to get them back.", + comment: "Warning message that deleted logs cannot be recovered" + ) + static let errorLoadingLogs = NSLocalizedString( + "com.jetpack.support.errorLoadingLogs", + value: "Error loading logs", + comment: "Error title when logs fail to load" + ) + static let unableToDeleteLogs = NSLocalizedString( + "com.jetpack.support.unableToDeleteLogs", + value: "Unable to delete logs", + comment: "Error title when log deletion fails" + ) + static let logFilesByDate = NSLocalizedString( + "com.jetpack.support.logFilesByDate", + value: "Log files by created date", + comment: "Section header for log files sorted by date" + ) + static let logRetentionNotice = NSLocalizedString( + "com.jetpack.support.logRetentionNotice", + value: "Up to seven days worth of logs are saved.", + comment: "Footer text explaining log retention policy" + ) + static let clearAllActivityLogs = NSLocalizedString( + "com.jetpack.support.clearAllActivityLogs", + value: "Clear All Activity Logs", + comment: "Button to clear all activity logs" + ) + static let noLogsFound = NSLocalizedString( + "com.jetpack.support.noLogsFound", + value: "No Logs Found", + comment: "Label shown when no log files are available" + ) + static let noLogsAvailable = NSLocalizedString( + "com.jetpack.support.noLogsAvailable", + value: "There are no activity logs available", + comment: "Description shown when no log files are available" + ) + static let loadingLogs = NSLocalizedString( + "com.jetpack.support.loadingLogs", + value: "Loading logs...", + comment: "Progress message while loading log files" + ) + + // MARK: - ActivityLogSharingView.swift + + static let share = NSLocalizedString( + "com.jetpack.support.share", + value: "Share", + comment: "Button to share content" + ) + static let shareActivityLog = NSLocalizedString( + "com.jetpack.support.shareActivityLog", + value: "Share Activity Log", + comment: "Navigation title for sharing activity log" + ) + static let sharingWithSupport = NSLocalizedString( + "com.jetpack.support.sharingWithSupport", + value: "Sharing with support!", + comment: "Message shown when sharing log with support" + ) + static let newSupportTicket = NSLocalizedString( + "com.jetpack.support.newSupportTicket", + value: "New Support Ticket", + comment: "Option to create a new support ticket" + ) + static let exportAsFile = NSLocalizedString( + "com.jetpack.support.exportAsFile", + value: "Export as File", + comment: "Option to export log as a file" + ) + static let sendLogsToSupport = NSLocalizedString( + "com.jetpack.support.sendLogsToSupport", + value: "Send logs directly to support team", + comment: "Description for sending logs to support" + ) + static let saveAsFile = NSLocalizedString( + "com.jetpack.support.saveAsFile", + value: "Save as a file to share or store", + comment: "Description for saving log as a file" + ) + + // MARK: - ConversationListView.swift + + static let conversations = NSLocalizedString( + "com.jetpack.support.conversations", + value: "Conversations", + comment: "Navigation title for bot conversations list" + ) + static let noConversations = NSLocalizedString( + "com.jetpack.support.noConversations", + value: "No Conversations", + comment: "Label shown when there are no bot conversations" + ) + static let startNewConversation = NSLocalizedString( + "com.jetpack.support.startNewConversation", + value: "Start a new conversation using the button above", + comment: "Description encouraging user to start a new conversation" + ) + static let loadingBotConversations = NSLocalizedString( + "com.jetpack.support.loadingBotConversations", + value: "Loading Bot Conversations", + comment: "Progress message while loading bot conversations" + ) + static let unableToLoadConversations = NSLocalizedString( + "com.jetpack.support.unableToLoadConversations", + value: "Unable to load conversations", + comment: "Error title when bot conversations fail to load" + ) + + // MARK: - ConversationView.swift + + static let openSupportTicket = NSLocalizedString( + "com.jetpack.support.openSupportTicket", + value: "Open a Support Ticket", + comment: "Button to open a support ticket" + ) + static let loadingBotConversationMessages = NSLocalizedString( + "com.jetpack.support.loadingBotConversationMessages", + value: "Loading Messages", + comment: "Progress message while loading conversation messages" + ) + static let unableToLoadMessages = NSLocalizedString( + "com.jetpack.support.unableToLoadMessages", + value: "Unable to Load Messages", + comment: "Error title when messages fail to load" + ) + + // MARK: - ConversationBotIntro.swift + + static let botGreeting = NSLocalizedString( + "com.jetpack.support.botGreeting", + value: "Howdy %1$@!", + comment: "Bot greeting message. %1$@ is the user's name" + ) + static let botIntroduction = NSLocalizedString( + "com.jetpack.support.botIntroduction", + value: "I'm your personal AI assistant. I can help with any questions about your site or account.", + comment: "Bot introduction message explaining its purpose" + ) + + // MARK: - ThinkingView.swift + + static let thinking = NSLocalizedString( + "com.jetpack.support.thinking", + value: "Thinking...", + comment: "Progress message shown while bot is thinking" + ) + + // MARK: - SupportConversationView.swift + + static let conversationEnded = NSLocalizedString( + "com.jetpack.support.conversationEnded", + value: "End of conversation. No further replies are possible.", + comment: "Message shown at end of closed support conversation" + ) + + // MARK: - ScreenshotPicker.swift + + static let attachmentLimit = NSLocalizedString( + "com.jetpack.support.attachmentLimit", + value: "Attachment Limit: %1$@ / %2$@", + comment: "Format string for attachment size limit. %1$@ is current size, %2$@ is maximum size" + ) + + // MARK: - ErrorView.swift + + static let tryAgain = NSLocalizedString( + "com.jetpack.support.tryAgain", + value: "Try Again", + comment: "Button to retry a failed operation" + ) + + // MARK: - AttachmentListView.swift + + static let loadingImage = NSLocalizedString( + "com.jetpack.support.loadingImage", + value: "Loading Image", + comment: "Progress message while loading an image attachment" + ) + static let loadingVideo = NSLocalizedString( + "com.jetpack.support.loadingVideo", + value: "Loading Video", + comment: "Progress message while loading a video attachment" + ) + static let unableToDisplayVideo = NSLocalizedString( + "com.jetpack.support.unableToDisplayVideo", + value: "Unable to display video", + comment: "Error title when video cannot be loaded or played" + ) + + // MARK: - OverlayProgressView.swift + + static let loadingLatestContent = NSLocalizedString( + "com.jetpack.support.loadingLatestContent", + value: "Loading latest content", + comment: "Progress message shown in overlay while refreshing content" + ) } diff --git a/Modules/Sources/Support/Model/ApplicationLog.swift b/Modules/Sources/Support/Model/ApplicationLog.swift index b81fdb1ca5b8..20cca4eb414a 100644 --- a/Modules/Sources/Support/Model/ApplicationLog.swift +++ b/Modules/Sources/Support/Model/ApplicationLog.swift @@ -3,7 +3,7 @@ import SwiftUI import CoreTransferable import UniformTypeIdentifiers -public struct ApplicationLog: Identifiable, Sendable { +public struct ApplicationLog: Identifiable, Sendable, Equatable { public let path: URL public let createdAt: Date public let modifiedAt: Date diff --git a/Modules/Sources/Support/Model/BotConversation.swift b/Modules/Sources/Support/Model/BotConversation.swift index 6eb237952bce..fb9ec1f1d2a4 100644 --- a/Modules/Sources/Support/Model/BotConversation.swift +++ b/Modules/Sources/Support/Model/BotConversation.swift @@ -3,12 +3,14 @@ import Foundation public struct BotConversation: Identifiable, Codable, Sendable, Hashable { public let id: UInt64 public let title: String + public let createdAt: Date public let userWantsHumanSupport: Bool public let messages: [BotMessage] - public init(id: UInt64, title: String, messages: [BotMessage]) { + public init(id: UInt64, title: String, createdAt: Date, messages: [BotMessage]) { self.id = id self.title = title + self.createdAt = createdAt self.messages = messages self.userWantsHumanSupport = messages.contains(where: { $0.userWantsToTalkToHuman }) } @@ -17,9 +19,14 @@ public struct BotConversation: Identifiable, Codable, Sendable, Hashable { BotConversation( id: self.id, title: self.title, + createdAt: self.createdAt, messages: (self.messages + newMessages).sorted(by: { lhs, rhs in lhs.date < rhs.date }) ) } + + var formattedCreationDate: String { + RelativeDateTimeFormatter().localizedString(for: self.createdAt, relativeTo: .now) + } } diff --git a/Modules/Sources/Support/Model/SupportConversation.swift b/Modules/Sources/Support/Model/SupportConversation.swift index 753ae500240c..f9a09b0fc655 100644 --- a/Modules/Sources/Support/Model/SupportConversation.swift +++ b/Modules/Sources/Support/Model/SupportConversation.swift @@ -1,10 +1,41 @@ import Foundation +import SwiftUI + +public enum ConversationStatus: Sendable, Codable { + case waitingForSupport + case waitingForUser + case resolved + case closed + case unknown // Handles future server updates + + var title: String { + switch self { + case .waitingForSupport: "Waiting for support" + case .waitingForUser: "Waiting for you" + case .resolved: "Solved" + case .closed: "Closed" + case .unknown: "Unknown" + } + } + + var color: Color { + switch self { + case .waitingForSupport: Color.blue + case .waitingForUser: Color.orange + case .resolved: Color.green + case .closed: Color.gray + case .unknown: Color.orange + } + } +} + +public struct ConversationSummary: Identifiable, Hashable, Sendable, Codable, Equatable { -public struct ConversationSummary: Identifiable, Hashable, Sendable, Codable { public let id: UInt64 public let title: String public let description: String public let attributedDescription: AttributedString + public let status: ConversationStatus /// The `description` with any markdown formatting stripped out public let plainTextDescription: String @@ -14,6 +45,7 @@ public struct ConversationSummary: Identifiable, Hashable, Sendable, Codable { id: UInt64, title: String, description: String, + status: ConversationStatus, lastMessageSentAt: Date ) { self.id = id @@ -21,15 +53,17 @@ public struct ConversationSummary: Identifiable, Hashable, Sendable, Codable { self.description = description self.attributedDescription = convertMarkdownTextToAttributedString(description) self.plainTextDescription = NSAttributedString(attributedDescription).string + self.status = status self.lastMessageSentAt = lastMessageSentAt } } -public struct Conversation: Identifiable, Sendable, Codable { +public struct Conversation: Identifiable, Sendable, Codable, Equatable { public let id: UInt64 public let title: String public let description: String public let lastMessageSentAt: Date + public let status: ConversationStatus public let messages: [Message] public init( @@ -37,12 +71,14 @@ public struct Conversation: Identifiable, Sendable, Codable { title: String, description: String, lastMessageSentAt: Date, + status: ConversationStatus, messages: [Message] ) { self.id = id self.title = title self.description = description self.lastMessageSentAt = lastMessageSentAt + self.status = status self.messages = messages } @@ -52,12 +88,20 @@ public struct Conversation: Identifiable, Sendable, Codable { title: self.title, description: self.description, lastMessageSentAt: message.createdAt, + status: self.status, messages: self.messages + [message] ) } + + /// Will the server accept a reply to this conversation? + /// + /// Unrelated to whether the user is eligible for support. + var canAcceptReply: Bool { + status != .closed + } } -public struct Message: Identifiable, Sendable, Codable { +public struct Message: Identifiable, Sendable, Codable, Equatable { public let id: UInt64 public let content: String @@ -91,10 +135,63 @@ public struct Message: Identifiable, Sendable, Codable { } } -public struct Attachment: Identifiable, Sendable, Codable { +public struct Attachment: Identifiable, Sendable, Codable, Equatable { + + public struct Dimensions: Sendable, Codable, Equatable { + let width: UInt64 + let height: UInt64 + + public init(width: UInt64, height: UInt64) { + self.width = width + self.height = height + } + } + public let id: UInt64 + public let filename: String + public let contentType: String + public let fileSize: UInt64 + public let url: URL + + public let dimensions: Dimensions? - public init(id: UInt64) { + public init( + id: UInt64, + filename: String, + contentType: String, + fileSize: UInt64, + url: URL, + dimensions: Dimensions? = nil + ) { self.id = id + self.filename = filename + self.contentType = contentType + self.fileSize = fileSize + self.url = url + self.dimensions = dimensions + } + + var isImage: Bool { + contentType.hasPrefix("image/") + } + + var isVideo: Bool { + contentType.hasPrefix("video/") + } + + var isPdf: Bool { + contentType == "application/pdf" + } + + var icon: String { + if isVideo { + return "film" + } + + if isPdf { + return "text.document" + } + + return "doc" } } diff --git a/Modules/Sources/Support/SupportDataProvider.swift b/Modules/Sources/Support/SupportDataProvider.swift index 84f003d6401e..3991debdfa22 100644 --- a/Modules/Sources/Support/SupportDataProvider.swift +++ b/Modules/Sources/Support/SupportDataProvider.swift @@ -1,5 +1,6 @@ import Foundation -import WordPressCore +import AsyncImageKit +import WordPressCoreProtocols public enum SupportFormAction { case viewApplicationLogList @@ -32,6 +33,8 @@ public final class SupportDataProvider: ObservableObject, Sendable { private let botConversationDataProvider: BotConversationDataProvider private let userDataProvider: CurrentUserDataProvider private let supportConversationDataProvider: SupportConversationDataProvider + private let diagnosticsDataProvider: DiagnosticsDataProvider + let mediaHost: MediaHostProtocol private weak var supportDelegate: SupportDelegate? @@ -40,12 +43,16 @@ public final class SupportDataProvider: ObservableObject, Sendable { botConversationDataProvider: BotConversationDataProvider, userDataProvider: CurrentUserDataProvider, supportConversationDataProvider: SupportConversationDataProvider, + diagnosticsDataProvider: DiagnosticsDataProvider, + mediaHost: MediaHostProtocol, delegate: SupportDelegate? = nil ) { self.applicationLogProvider = applicationLogProvider self.botConversationDataProvider = botConversationDataProvider self.userDataProvider = userDataProvider self.supportConversationDataProvider = supportConversationDataProvider + self.diagnosticsDataProvider = diagnosticsDataProvider + self.mediaHost = mediaHost self.supportDelegate = delegate } @@ -143,6 +150,10 @@ public final class SupportDataProvider: ObservableObject, Sendable { } } + var maximumUploadSize: CGFloat { + CGFloat(self.supportConversationDataProvider.maximumUploadSize) + } + // Application Logs public func fetchApplicationLogs() async throws -> [ApplicationLog] { try await self.applicationLogProvider.fetchApplicationLogs() @@ -161,6 +172,17 @@ public final class SupportDataProvider: ObservableObject, Sendable { self.userDid(.deleteAllApplicationLogs) try await self.applicationLogProvider.deleteAllApplicationLogs() } + + // Diagnostics + public func fetchDiskCacheUsage() async throws -> DiskCacheUsage { + try await self.diagnosticsDataProvider.fetchDiskCacheUsage() + } + + public func clearDiskCache( + progress: (@escaping @Sendable (CacheDeletionProgress) async throws -> Void) + ) async throws { + try await self.diagnosticsDataProvider.clearDiskCache(progress: progress) + } } public protocol SupportFormDataProvider { @@ -211,6 +233,11 @@ public protocol CurrentUserDataProvider: Actor { nonisolated func fetchCurrentSupportUser() throws -> any CachedAndFetchedResult } +public protocol DiagnosticsDataProvider: Actor { + func fetchDiskCacheUsage() async throws -> DiskCacheUsage + func clearDiskCache(progress: (@escaping @Sendable (CacheDeletionProgress) async throws -> Void)) async throws +} + public protocol ApplicationLogDataProvider: Actor { func readApplicationLog(_ log: ApplicationLog) async throws -> String func fetchApplicationLogs() async throws -> [ApplicationLog] @@ -239,6 +266,8 @@ public protocol BotConversationDataProvider: Actor { } public protocol SupportConversationDataProvider: Actor { + nonisolated var maximumUploadSize: UInt64 { get } + nonisolated func loadSupportConversations() throws -> any CachedAndFetchedResult<[ConversationSummary]> nonisolated func loadSupportConversation(id: UInt64) throws -> any CachedAndFetchedResult diff --git a/Modules/Sources/Support/UI/Application Logs/ActivityLogDetailView.swift b/Modules/Sources/Support/UI/Application Logs/ActivityLogDetailView.swift index 4e9890c94c88..88f8f48ced15 100644 --- a/Modules/Sources/Support/UI/Application Logs/ActivityLogDetailView.swift +++ b/Modules/Sources/Support/UI/Application Logs/ActivityLogDetailView.swift @@ -83,7 +83,7 @@ struct ActivityLogDetailView: View { @ViewBuilder var loadingView: some View { - ProgressView("Loading log content...").padding() + ProgressView(Localization.loadingLogContent).padding() } @ViewBuilder diff --git a/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift b/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift index 09ec53ec1102..d8fa91104e33 100644 --- a/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift +++ b/Modules/Sources/Support/UI/Application Logs/ActivityLogListView.swift @@ -36,12 +36,12 @@ public struct ActivityLogListView: View { listView(logFiles: logFiles, deletionState: deletionState) case .error(let error): ErrorView( - title: "Error loading logs", + title: Localization.errorLoadingLogs, message: error.localizedDescription ) } } - .navigationTitle("Application Logs") + .navigationTitle(Localization.applicationLogsTitle) .overlay { if case .loaded(_, let deletionState) = self.state { switch deletionState { @@ -49,24 +49,24 @@ public struct ActivityLogListView: View { case .deleting: ProgressView() case .confirm: EmptyView() // Do nothing case .deletionError(let error): ErrorView( - title: "Unable to delete logs", + title: Localization.unableToDeleteLogs, message: error.localizedDescription ) } } } - .alert("Are you sure you want to delete all logs?", isPresented: self.$isConfirmingDeletion, actions: { + .alert(Localization.confirmDeleteAllLogs, isPresented: self.$isConfirmingDeletion, actions: { - Button ("Delete all Logs", role: .destructive) { + Button (Localization.deleteAllLogs, role: .destructive) { self.deleteAllLogFiles() } - Button("Cancel", role: .cancel) { + Button(Localization.cancel, role: .cancel) { // Alert will be dismissed on its own } }, message: { - Text("You won't be able to get them back.") + Text(Localization.cannotRecoverLogs) }) .onAppear { self.dataProvider.userDid(.viewApplicationLogList) @@ -96,27 +96,27 @@ public struct ActivityLogListView: View { } }.onDelete(perform: self.deleteLogFiles) } header: { - Text("Log files by created date") + Text(Localization.logFilesByDate) } footer: { - Text("Up to seven days worth of logs are saved.") + Text(Localization.logRetentionNotice) } - Button("Clear All Activity Logs") { + Button(Localization.clearAllActivityLogs) { self.isConfirmingDeletion = true } } } else { ContentUnavailableView { - Label("No Logs Found", systemImage: "doc.text") + Label(Localization.noLogsFound, systemImage: "doc.text") } description: { - Text("There are no activity logs available") + Text(Localization.noLogsAvailable) } } } @ViewBuilder var loadingView: some View { - ProgressView("Loading logs...") + ProgressView(Localization.loadingLogs) } func deleteLogFiles(_ indexSet: IndexSet) { diff --git a/Modules/Sources/Support/UI/Application Logs/ActivityLogSharingView.swift b/Modules/Sources/Support/UI/Application Logs/ActivityLogSharingView.swift index 57c53c1ad8ad..aa792164ac06 100644 --- a/Modules/Sources/Support/UI/Application Logs/ActivityLogSharingView.swift +++ b/Modules/Sources/Support/UI/Application Logs/ActivityLogSharingView.swift @@ -1,8 +1,15 @@ import SwiftUI -enum SharingOption: String, CaseIterable { - case supportTicket = "New Support Ticket" - case exportFile = "Export as File" +enum SharingOption: CaseIterable { + case supportTicket + case exportFile + + var title: String { + switch self { + case .supportTicket: Localization.newSupportTicket + case .exportFile: Localization.exportAsFile + } + } var systemImage: String { switch self { @@ -13,8 +20,8 @@ enum SharingOption: String, CaseIterable { var description: String { return switch self { - case .supportTicket: "Send logs directly to support team" - case .exportFile: "Save as a file to share or store" + case .supportTicket: Localization.sendLogsToSupport + case .exportFile: Localization.saveAsFile } } } @@ -55,7 +62,7 @@ struct ActivityLogSharingView: View { case .exportFile: ShareLink(item: applicationLog.path) { Spacer() - Text("Share") + Text(Localization.share) Spacer() } .buttonStyle(.borderedProminent) @@ -65,7 +72,7 @@ struct ActivityLogSharingView: View { case .supportTicket: NavigationLink(destination: self.destination) { Spacer() - Text("Share") + Text(Localization.share) Spacer() } .buttonStyle(.borderedProminent) @@ -76,11 +83,11 @@ struct ActivityLogSharingView: View { .padding(.horizontal) } .padding(.vertical) - .navigationTitle("Share Activity Log") + .navigationTitle(Localization.shareActivityLog) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button("Cancel") { + Button(Localization.cancel) { dismiss() } } @@ -102,7 +109,7 @@ struct SharingOptionRow: View { .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 4) { - Text(option.rawValue) + Text(option.title) .font(.headline) .foregroundColor(.primary) @@ -142,7 +149,7 @@ struct SharingOptionRow: View { Color.clear .sheet(isPresented: $isPresented) { ActivityLogSharingView(applicationLog: SupportDataProvider.applicationLog) { - AnyView(erasing: Text("Sharing with support!")) + AnyView(erasing: Text(Localization.sharingWithSupport)) }.presentationDetents([.medium]) } } diff --git a/Modules/Sources/Support/UI/Bot Conversations/CompositionView.swift b/Modules/Sources/Support/UI/Bot Conversations/CompositionView.swift index b610cf2380e0..e7d70d8fa592 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/CompositionView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/CompositionView.swift @@ -89,7 +89,6 @@ struct CompositionView: View { VStack { Spacer() CompositionView(isDisabled: false) { message in - debugPrint(message) // Do nothing } } @@ -105,7 +104,7 @@ struct CompositionView: View { VStack { Spacer() CompositionView(isDisabled: false) { message in - // Do nothing + // You'd do something with the message if this weren't a preview } } } diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationBotIntro.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationBotIntro.swift index 2f5a666c847a..b0430d4e5672 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ConversationBotIntro.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationBotIntro.swift @@ -13,7 +13,7 @@ struct ConversationBotIntro: View { VStack(alignment: .leading, spacing: 16) { // Greeting with wave emoji HStack { - Text("Howdy \(currentUser.username)!") + Text(String.localizedStringWithFormat(Localization.botGreeting, currentUser.username)) .font(.title2) .fontWeight(.semibold) @@ -22,7 +22,7 @@ struct ConversationBotIntro: View { } // Description text - Text("I'm your personal AI assistant. I can help with any questions about your site or account.") + Text(Localization.botIntroduction) .font(.body) .foregroundColor(.secondary) .lineLimit(nil) diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift index 8affee3f884c..58c70bb72e76 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift @@ -2,15 +2,16 @@ import SwiftUI public struct ConversationListView: View { - enum ViewState { - case loading - case partiallyLoaded([BotConversation]) + enum ViewState: Equatable { + case start + case loading(Task) + case partiallyLoaded([BotConversation], fetchTask: Task) case loaded([BotConversation], ViewSubstate?) - case loadingConversationsError(Error) + case loadingConversationsError(String) var conversations: [BotConversation]? { return switch self { - case .partiallyLoaded(let conversations): conversations + case .partiallyLoaded(let conversations, _): conversations case .loaded(let conversations, _): conversations default: nil } @@ -68,16 +69,16 @@ public struct ConversationListView: View { } } - enum ViewSubstate { + enum ViewSubstate: Equatable { case deletingConversations(Task) - case deletingConversationsError(Error) + case deletingConversationsError(String) } @EnvironmentObject private var dataProvider: SupportDataProvider @State - var state: ViewState = .loading + var state: ViewState = .start @State var selectedConversations = Set() @@ -91,18 +92,18 @@ public struct ConversationListView: View { public var body: some View { 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 .start, .loading: + FullScreenProgressView(Localization.loadingBotConversations) + case .partiallyLoaded(let conversations, _), .loaded(let conversations, _): + self.conversationList(conversations) case .loadingConversationsError(let error): - ErrorView( - title: "Unable to load conversations", - message: error.localizedDescription + FullScreenErrorView( + title: Localization.unableToLoadConversations, + message: error ) } } - .navigationTitle("Conversations") + .navigationTitle(Localization.conversations) .toolbar { ToolbarItem(placement: .primaryAction) { NavigationLink { @@ -131,9 +132,9 @@ public struct ConversationListView: View { private func conversationList(_ conversations: [BotConversation]) -> some View { if case .loaded = self.state, conversations.isEmpty { ContentUnavailableView { - Label("No Conversations", systemImage: "message") + Label(Localization.noConversations, systemImage: "message") } description: { - Text("Start a new conversation using the button above") + Text(Localization.startNewConversation) } } else { List(conversations) { conversation in @@ -148,42 +149,47 @@ public struct ConversationListView: View { } private func loadConversations() async { - do { - let fetch = try await dataProvider.loadConversations() + guard case .start = state else { + return + } - if let cachedConversations = try await fetch.cachedResult() { - await MainActor.run { - self.state = .partiallyLoaded(cachedConversations) - } - } + self.state = .loading(self.cacheTask) + } - let fetchedConversations = try await fetch.fetchedResult() + private func reloadConversations() async { + guard case .loaded(let conversations, _) = state else { + return + } - await MainActor.run { - self.state = .loaded(fetchedConversations, .none) - } + self.state = .partiallyLoaded(conversations, fetchTask: self.fetchTask) + } - } catch { - debugPrint("🚩 Load conversations error: \(error.localizedDescription)") - await MainActor.run { - self.state = .loadingConversationsError(error) + private var cacheTask: Task { + Task { + do { + if let cachedResult = try await dataProvider.loadConversations().cachedResult() { + self.state = .partiallyLoaded(cachedResult, fetchTask: self.fetchTask) + } else { + await self.fetchTask.value + } + } catch { + self.state = .loadingConversationsError(error.localizedDescription) } } } - private func reloadConversations() async { - do { - let conversationList = try await self.dataProvider.loadConversations().fetchedResult() - await MainActor.run { - self.state = .loaded(conversationList, .none) - } - } catch { - await MainActor.run { - self.state = .loadingConversationsError(error) + private var fetchTask: Task { + Task { + do { + let fetchedConversations = try await dataProvider.loadConversations().fetchedResult() + self.state = .loaded(fetchedConversations, .none) + } catch { + self.state = .loadingConversationsError(error.localizedDescription) } } } + @MainActor private func deleteConversations(at indexSet: IndexSet) { guard let conversationIds = self.state.conversations?.map({ $0.id }) else { return @@ -192,14 +198,12 @@ public struct ConversationListView: View { self.state = self.state.addSubstate(.deletingConversations(Task { do { try await self.dataProvider.delete(conversationIds: conversationIds) - await MainActor.run { - self.state = self.state.clearSubstate() - } + self.state = self.state.clearSubstate() } catch { - await MainActor.run { - self.state = self.state.updateSubstate(.deletingConversationsError(error)) - } + self.state = self.state.updateSubstate( + .deletingConversationsError(error.localizedDescription) + ) } })) } @@ -212,24 +216,13 @@ struct ConversationRow: View { 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) + .font(.body) + .padding(.bottom, 4) - Text(lastMessage.formattedTime) - .font(.caption) - .foregroundColor(.gray) - } else { - Text("No messages") - .font(.subheadline) - .foregroundColor(.secondary) - } + Text(conversation.formattedCreationDate) + .font(.caption) + .foregroundColor(.secondary) } - .padding(.vertical, 4) } } @@ -239,10 +232,6 @@ struct ConversationRow: View { 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 index 887bd2801987..8caf3365d2cf 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift @@ -3,50 +3,40 @@ import SwiftUI public struct ConversationView: View { enum ViewState: Equatable { - case start - case loadingMessages - case loadingMessagesError(Error) - case partiallyLoaded(conversation: BotConversation) + case start(conversation: BotConversation?) + case loadingMessages(conversation: BotConversation?, Task) + case loadingMessagesError(conversation: BotConversation?, String) + case partiallyLoaded(conversation: BotConversation, fetchTask: Task) case loaded(conversation: BotConversation, substate: ViewSubstate?) case startingNewConversation(substate: ViewSubstate?) - case conversationNotFound - - static func == (lhs: ConversationView.ViewState, rhs: ConversationView.ViewState) -> Bool { - return switch (lhs, rhs) { - 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 + case .start(let conversation): + conversation + case .loadingMessages(let conversation, _): + conversation + case .partiallyLoaded(let conversation, _): + conversation + case .loaded(conversation: let conversation, _): + conversation + case .loadingMessagesError(let conversation, _): + conversation default: nil } } + var conversationId: UInt64? { + self.conversation?.id + } + + var conversationTitle: String { + self.conversation?.title ?? "New Support Conversation" + } + var messages: [BotMessage] { switch self { - case .partiallyLoaded(let conversation): conversation.messages + case .partiallyLoaded(let conversation, _): conversation.messages case .loaded(conversation: let conversation, _): conversation.messages default: [] } @@ -54,7 +44,7 @@ public struct ConversationView: View { var userWantsHumanSupport: Bool { switch self { - case .partiallyLoaded(let conversation): conversation.userWantsHumanSupport + case .partiallyLoaded(let conversation, _): conversation.userWantsHumanSupport case .loaded(conversation: let conversation, _): conversation.userWantsHumanSupport default: false } @@ -171,7 +161,7 @@ public struct ConversationView: View { return .loaded( conversation: currentConversation, - substate: .sendingMessageError(error) + substate: .sendingMessageError(error.localizedDescription) ) } else { guard self.substate != nil else { @@ -179,7 +169,7 @@ public struct ConversationView: View { } return .startingNewConversation( - substate: .sendingMessageError(error) + substate: .sendingMessageError(error.localizedDescription) ) } } @@ -195,11 +185,7 @@ public struct ConversationView: View { 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 - } + case sendingMessageError(String) var isThinking: Bool { if case .sendingMessage(_, let thinking, _) = self { @@ -225,7 +211,7 @@ public struct ConversationView: View { var currentUser: SupportUser @State - var state: ViewState = .start + var state: ViewState @State private var showThinkingView = false @@ -233,16 +219,47 @@ public struct ConversationView: View { @Namespace var bottom - private let conversationId: UInt64? - - private var loadingTask: Task? - public init(conversation: BotConversation?, currentUser: SupportUser) { - self.conversationId = conversation?.id + self.state = .start(conversation: conversation) self.currentUser = currentUser } public var body: some View { + VStack { + switch self.state { + case .start, .loadingMessages: + FullScreenProgressView(Localization.loadingBotConversationMessages) + case .partiallyLoaded(let conversation, _), .loaded(let conversation, _): + self.conversationView(messages: conversation.messages) + case .loadingMessagesError(_, let message): + FullScreenErrorView( + title: Localization.unableToLoadMessages, + message: message + ) + case .startingNewConversation: + self.conversationView(messages: []) + } + } + .navigationTitle(self.state.conversationTitle) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .overlay { + OverlayProgressView(shouldBeVisible: state.isPartiallyLoaded) + } + .onAppear { + if let conversationId = self.state.conversationId { + self.dataProvider.userDid(.viewSupportBotConversation(conversationId: conversationId)) + } else { + self.dataProvider.userDid(.startSupportBotConversation) + } + } + .task(self.loadExistingConversation) + .refreshable(action: self.reloadConversation) + } + + @ViewBuilder + func conversationView(messages: [BotMessage]) -> some View { ZStack { ScrollViewReader { proxy in List() { @@ -253,7 +270,7 @@ public struct ConversationView: View { loadingMessagesError Section { - ForEach(self.state.messages) { message in + ForEach(messages) { message in MessageView(message: message).id(message.id) } @@ -267,7 +284,7 @@ public struct ConversationView: View { switchToHumanSupport - Text("").padding(.bottom, 0) + Text("").padding(.bottom, 4) .listRowInsets(.zero) .listRowBackground(Color.clear) .listRowSpacing(0) @@ -279,11 +296,10 @@ public struct ConversationView: View { scrollToBottom(using: proxy, animated: false) } } + .onAppear { + scrollToBottom(using: proxy, animated: false) + } } - .navigationTitle(self.state.conversationTitle) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif VStack { Spacer() CompositionView( @@ -292,18 +308,6 @@ 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) } @ViewBuilder @@ -343,10 +347,10 @@ public struct ConversationView: View { @ViewBuilder var loadingMessagesError: some View { - if case .loadingMessagesError(let error) = self.state { + if case .loadingMessagesError(_, let error) = self.state { ErrorView( title: "Unable to load messages", - message: error.localizedDescription + message: error ) .transition(.asymmetric( insertion: .move(edge: .top).combined(with: .opacity), @@ -361,7 +365,7 @@ public struct ConversationView: View { if case .sendingMessageError(let error) = substate { ErrorView( title: "Unable to send message", - message: error.localizedDescription + message: error ) .transition(.asymmetric( insertion: .move(edge: .top).combined(with: .opacity), @@ -396,7 +400,7 @@ public struct ConversationView: View { supportIdentity: self.currentUser ).environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller } label: { - Text("Open a Support Ticket") + Text(Localization.openSupportTicket) .font(.headline) .padding(.vertical) .frame(maxWidth: .infinity) @@ -414,43 +418,54 @@ public struct ConversationView: View { } private func loadExistingConversation() async { - self.state = .loadingMessages + if let conversation = self.state.conversation { + self.state = .loadingMessages(conversation: conversation, cacheTask) + } else { + self.state = .startingNewConversation(substate: nil) + } + } - do { - guard let conversationId = self.conversationId else { - await MainActor.run { - self.state = .startingNewConversation(substate: nil) - } + private func reloadConversation() async { + guard case .loaded(let conversation, _) = self.state else { + return + } + self.state = .partiallyLoaded(conversation: conversation, fetchTask: self.fetchTask) + } + + private var cacheTask: Task { + Task { + guard let conversation = self.state.conversation else { return } - let fetch = try await self.dataProvider.loadConversation(id: conversationId) - - if let cachedConversation = try await fetch.cachedResult() { - await MainActor.run { - self.state = .partiallyLoaded(conversation: cachedConversation) + do { + if let cachedResult = try await self.dataProvider.loadConversation(id: conversation.id).cachedResult() { + self.state = .partiallyLoaded(conversation: cachedResult, fetchTask: self.fetchTask) + } else { + await self.fetchTask.value } - } - - let conversation = try await fetch.fetchedResult() - - await MainActor.run { - self.state = .loaded(conversation: conversation, substate: nil) - } - } catch { - await MainActor.run { - self.state = .loadingMessagesError(error) + } catch { + self.state = .loadingMessagesError(conversation: conversation, error.localizedDescription) } } } - private func reloadConversation() async { - guard case .loaded(let conversation, _) = self.state else { - return + private var fetchTask: Task { + Task { + guard let conversation = self.state.conversation else { + return + } + + do { + let result = try await self.dataProvider.loadConversation(id: conversation.id).fetchedResult() + self.state = .loaded(conversation: result, substate: .none) + } catch { + self.state = .loadingMessagesError(conversation: conversation, error.localizedDescription) + } } - self.state = .partiallyLoaded(conversation: conversation) } + @MainActor private func sendMessage(_ message: String) { self.state = self.state.transitioningToSendingMessage(message: message, task: Task { do { @@ -474,15 +489,11 @@ public struct ConversationView: View { // If we somehow got a response before the thinking view shows up, don't show it thinkingTask.cancel() - await MainActor.run { - self.state = self.state.transitioningToMessageSent( - updatedConversation: updatedConversation - ) - } + self.state = self.state.transitioningToMessageSent( + updatedConversation: updatedConversation + ) } catch { - await MainActor.run { - self.state = self.state.transitioningToMessageSendError(error) - } + self.state = self.state.transitioningToMessageSendError(error) } }) } diff --git a/Modules/Sources/Support/UI/Bot Conversations/ThinkingView.swift b/Modules/Sources/Support/UI/Bot Conversations/ThinkingView.swift index ef911824ccdd..b0aea567e01a 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ThinkingView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ThinkingView.swift @@ -9,7 +9,7 @@ struct ThinkingView: View { .foregroundColor(.gray) // Thinking text with shimmer effect - Text("Thinking...") + Text(Localization.thinking) .font(.system(size: 16, weight: .medium)) } .padding(.horizontal, 12) diff --git a/Modules/Sources/Support/UI/ChipView.swift b/Modules/Sources/Support/UI/ChipView.swift new file mode 100644 index 000000000000..6883f797d4ef --- /dev/null +++ b/Modules/Sources/Support/UI/ChipView.swift @@ -0,0 +1,115 @@ +import SwiftUI + +struct ChipView: View { + + private let string: String + private let color: Color + + @Environment(\.self) + private var environment + + @Environment(\.controlSize) + private var controlSize + + init(string: String, color: Color) { + self.string = string + self.color = color + } + + var body: some View { + Text(self.string) + .font(self.font) + .foregroundStyle(self.computedTextColor) + .padding(self.padding) + .background(self.color) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + var computedTextColor: Color { + let resolved = self.color.resolve(in: environment) + let r = resolved.red + let g = resolved.green + let b = resolved.blue + + let L = 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b) + return L > 0.5 ? .black : .white + } + + @inline(__always) + private func linearize(_ c: Float) -> Float { + return c <= 0.03928 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) + } + + var font: Font { + switch self.controlSize { + case .mini: .caption2 + case .small: .caption + case .regular: .body + case .large: .subheadline.weight(.regular) + case .extraLarge: .headline.weight(.regular) + @unknown default: .body + } + } + + var padding: EdgeInsets { + switch self.controlSize { + case .mini: EdgeInsets(top: 4, leading: 6, bottom: 4, trailing: 6) + case .small: EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) + case .regular: EdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10) + case .large: EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12) + case .extraLarge: EdgeInsets(top: 12, leading: 14, bottom: 12, trailing: 14) + @unknown default: EdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4) + } + } +} + +#Preview("Color") { + NavigationStack { + ScrollView { + HStack { + VStack(alignment: .leading) { + ChipView(string: "teal", color: .teal) + ChipView(string: "red", color: .red) + ChipView(string: "orange", color: .orange) + ChipView(string: "yellow", color: .yellow) + ChipView(string: "green", color: .green) + ChipView(string: "blue", color: .blue) + ChipView(string: "purple", color: .purple) + ChipView(string: "black", color: .black) + ChipView(string: "white", color: .white) + ChipView(string: "brown", color: .brown) + ChipView(string: "cyan", color: .cyan) + ChipView(string: "gray", color: .gray) + ChipView(string: "indigo", color: .indigo) + ChipView(string: "mint", color: .mint) + ChipView(string: "pink", color: .pink) + ChipView(string: "primary", color: .primary) + ChipView(string: "secondary", color: .secondary) + ChipView(string: "accent", color: .accentColor) + }.padding() + Spacer() + } + } + } +} + +#Preview("Size") { + NavigationStack { + ScrollView { + HStack { + VStack(alignment: .leading) { + ChipView(string: "mini", color: .accentColor) + .controlSize(.mini) + ChipView(string: "small", color: .accentColor) + .controlSize(.small) + ChipView(string: "regular", color: .accentColor) + .controlSize(.regular) + ChipView(string: "large", color: .accentColor) + .controlSize(.large) + ChipView(string: "extra large", color: .accentColor) + .controlSize(.extraLarge) + } + } + } + } +} diff --git a/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift b/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift index 8a6eac6cf227..956675eb4e55 100644 --- a/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift +++ b/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift @@ -1,5 +1,5 @@ import SwiftUI -import WordPressCore +import WordPressCoreProtocols public struct DiagnosticsView: View { @@ -11,14 +11,14 @@ public struct DiagnosticsView: View { public var body: some View { ScrollView { VStack(alignment: .leading) { - Text("Run common maintenance and troubleshooting tasks.") + Text(Localization.diagnosticsDescription) .foregroundStyle(.secondary) EmptyDiskCacheView() } .padding() } - .navigationTitle("Diagnostics") + .navigationTitle(Localization.diagnosticsTitle) .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 5b328ce02762..a91b895365cb 100644 --- a/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift +++ b/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift @@ -1,5 +1,5 @@ import SwiftUI -import WordPressCore +import WordPressCoreProtocols struct EmptyDiskCacheView: View { @@ -8,9 +8,9 @@ struct EmptyDiskCacheView: View { enum ViewState: Equatable { case loading - case loaded(usage: DiskCache.DiskCacheUsage) - case clearing(progress: Double, result: String) - case error(Error) + case loaded(usage: DiskCacheUsage) + case clearing(progress: Double, result: String, task: Task) + case error(String) var isClearingCache: Bool { if case .clearing = self { @@ -32,78 +32,110 @@ struct EmptyDiskCacheView: View { 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 + var task: Task? { + guard case .clearing(_, _, let task) = self else { + return nil + } + + return task + } + + var buttonText: String { + isClearingCache ? Localization.clearing : Localization.clearDiskCache + } + + var buttonImage: String { + isClearingCache ? "hourglass" : "trash" + } + + var primaryStatusText: String { + if case .loaded(let usage) = self { + if usage.isEmpty { + return Localization.cacheIsEmpty + } else { + return String + .localizedStringWithFormat(Localization.cacheFiles, usage.fileCount, usage.formattedDiskUsage) + .applyingNumericMorphology(for: usage.fileCount) + } } + + return "" + } + + var secondaryStatusText: String { + if case .clearing(let progress, _, _) = self { + return formatter.string(from: progress as NSNumber) ?? "" + } + + return "" + } + + var progressBarProgress: CGFloat { + guard case .clearing(let progress, _, _) = self else { + return 0 + } + + return progress + } + + var progressBarOpacity: CGFloat { + if case .clearing = self { + return 1.0 + } + + return 0 + } + + private var formatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + formatter.maximumFractionDigits = 0 + return formatter } } @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.", + title: Localization.clearDiskCache, + subtitle: Localization.clearDiskCacheDescription, systemImage: "externaldrive.badge.xmark" ) { VStack(alignment: .leading, spacing: 12) { Button { - Task { await clearDiskCache() } + clearDiskCache() } label: { - Label(self.state.isClearingCache ? "Clearing…" : "Clear Disk Cache", systemImage: self.state.isClearingCache ? "hourglass" : "trash") + Label(self.state.buttonText, systemImage: self.state.buttonImage) } .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.fileCount) cache files](inflect: true) (\(usage.formattedDiskUsage))") - .font(.footnote) - .foregroundStyle(.secondary) - } - case .clearing(let progress, let status): - ProgressView(value: progress) + if case .loading = state { + ProgressView(Localization.loadingDiskUsage) + } else { + ProgressView(value: self.state.progressBarProgress) .progressViewStyle(.linear) .tint(.accentColor) - .opacity(progress > 0 ? 1 : 0) - - Text(status) - .font(.caption) - .foregroundStyle(.secondary) + .opacity(self.state.progressBarOpacity) HStack { + Text("^[\(self.state.primaryStatusText)](inflect: true)") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() - Text("\(Int(progress * 100))%") + + Text(self.state.secondaryStatusText) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) + .opacity(self.state.progressBarOpacity) } - case .error(let error): - Text(error.localizedDescription) } - } } .task(self.fetchDiskCacheUsage) @@ -112,53 +144,60 @@ struct EmptyDiskCacheView: View { private func fetchDiskCacheUsage() async { do { - let usage = try await cache.diskUsage() - await MainActor.run { - self.state = .loaded(usage: usage) - } + let usage = try await dataProvider.fetchDiskCacheUsage() + self.state = .loaded(usage: usage) } catch { - await MainActor.run { - self.state = .error(error) - } + self.state = .error(error.localizedDescription) } } // Simulated async cache clearing with progress updates. - private func clearDiskCache() async { + private func clearDiskCache() { guard case .loaded(let usage) = state else { return } self.dataProvider.userDid(.emptyDiskCache(bytesSaved: usage.byteCount)) + self.state = .clearing(progress: 0, result: "", task: self.clearDiskCacheTask) + } - 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 - } + private var clearDiskCacheTask: Task { + Task { + guard case .clearing(_, _, let task) = state else { + return + } + do { + try await Task.runForAtLeast(.seconds(1.0)) { + // If the process takes less than a second, show the progress bar and percent for at least that long + try await dataProvider.clearDiskCache { @MainActor progress in + withAnimation { + self.state = .clearing( + progress: progress.progress, + result: Localization.working, + task: task + ) + } + } - await MainActor.run { - withAnimation { - self.state = .clearing(progress: progress, result: "Working") + await MainActor.run { + withAnimation { + self.state = .clearing( + progress: 1.0, + result: Localization.complete, + task: task + ) + } } } - } - await MainActor.run { + let usage = try await dataProvider.fetchDiskCacheUsage() + withAnimation { - self.state = .clearing(progress: 1.0, result: "Complete") + self.state = .loaded(usage: usage) } - } - } catch { - await MainActor.run { + } catch { withAnimation { - self.state = .error(error) + self.state = .error(error.localizedDescription) } } } @@ -166,5 +205,5 @@ struct EmptyDiskCacheView: View { } #Preview { - EmptyDiskCacheView() + EmptyDiskCacheView().environmentObject(SupportDataProvider.testing) } diff --git a/Modules/Sources/Support/UI/ErrorView.swift b/Modules/Sources/Support/UI/ErrorView.swift index 47c1dc69afeb..e73099c3ab67 100644 --- a/Modules/Sources/Support/UI/ErrorView.swift +++ b/Modules/Sources/Support/UI/ErrorView.swift @@ -42,7 +42,7 @@ public struct ErrorView: View { // Retry button (if action provided) if let retryAction { - Button("Try Again") { + Button(Localization.tryAgain) { retryAction() } .buttonStyle(.borderedProminent) @@ -59,6 +59,31 @@ public struct ErrorView: View { } } +public struct FullScreenErrorView: View { + + let title: String + let message: String + let systemImage: String + + public init( + title: String = "Something went wrong", + message: String = "Please try again later", + systemImage: String = "exclamationmark.triangle.fill" + ) { + self.title = title + self.message = message + self.systemImage = systemImage + } + + public var body: some View { + ContentUnavailableView( + self.title, + systemImage: self.systemImage, + description: Text(self.message) + ) + } +} + #Preview { VStack(spacing: 20) { // Basic error view @@ -70,7 +95,7 @@ public struct ErrorView: View { message: "Unable to connect to the server. Check your internet connection and try again.", systemImage: "wifi.exclamationmark", retryAction: { - print("Retry tapped") + // You'd do something here if this wasn't a preview } ) @@ -83,3 +108,11 @@ public struct ErrorView: View { } .background(.gray.opacity(0.1)) } + +#Preview("Full Screen Error View") { + FullScreenErrorView( + title: "Network Error", + message: "Unable to connect to the server. Check your internet connection and try again.", + systemImage: "wifi.exclamationmark" + ) +} diff --git a/Modules/Sources/Support/UI/FullScreenProgressView.swift b/Modules/Sources/Support/UI/FullScreenProgressView.swift new file mode 100644 index 000000000000..fa1915895a38 --- /dev/null +++ b/Modules/Sources/Support/UI/FullScreenProgressView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct FullScreenProgressView: View { + + private let string: String + + init(_ string: String) { + self.string = string + } + + var body: some View { + VStack { + Spacer() + HStack { + Spacer() + ProgressView(string) + Spacer() + } + Spacer() + } + } +} + +#Preview { + FullScreenProgressView("Loading Stuff") +} diff --git a/Modules/Sources/Support/UI/OverlayProgressView.swift b/Modules/Sources/Support/UI/OverlayProgressView.swift index ebd1466ef6ee..ecdb0db124b1 100644 --- a/Modules/Sources/Support/UI/OverlayProgressView.swift +++ b/Modules/Sources/Support/UI/OverlayProgressView.swift @@ -3,9 +3,20 @@ import SwiftUI struct OverlayProgressView: View { enum ViewState { + /// The view is hidden case mustBeHidden + /// The view is visible case mustBeVisible - case inherit + /// The view has been signaled it should hide, but the `minimumDisplayTime` has not yet elapsed + case awaitingHiding(until: Date) + + var isVisible: Bool { + switch self { + case .mustBeVisible: true + case .mustBeHidden: false + case .awaitingHiding: true + } + } } let shouldBeVisible: Bool @@ -14,53 +25,61 @@ struct OverlayProgressView: View { @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 - } - } + @State + private var canHideAt: Date? - init(shouldBeVisible: Bool, minimumDisplayTime: Duration = .seconds(3.8)) { + init(shouldBeVisible: Bool, minimumDisplayTime: Duration = .seconds(1.8)) { self.shouldBeVisible = shouldBeVisible self.minimumDisplayTime = minimumDisplayTime } var body: some View { - HStack(spacing: 12) { - ProgressView() - .progressViewStyle(.circular) + TimelineView(.periodic(from: .now, by: 1.0)) { context in - 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(.easeOut) { - self.state = .mustBeVisible + HStack(spacing: 12) { + ProgressView() + .progressViewStyle(.circular) + + Text(Localization.loadingLatestContent) + .font(.callout) + .foregroundStyle(.primary) } - } - .task { - try? await Task.sleep(for: self.minimumDisplayTime) - await MainActor.run { - withAnimation(.easeOut) { - self.state = .inherit + .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(state.isVisible ? 1 : 0) + .offset(y: state.isVisible ? 0 : -12) + .accessibilityElement(children: .combine) + .accessibilityLabel(Localization.loadingLatestContent) + .accessibilityAddTraits(.isStaticText) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, 24) + .onChange(of: context.date, { oldValue, newValue in + if case .awaitingHiding(let until) = state { + if until.hasPast { + withAnimation { + self.state = .mustBeHidden + } + } + } + }) + .onChange(of: self.shouldBeVisible) { oldValue, newValue in + withAnimation { + if newValue { + self.state = .mustBeVisible + self.canHideAt = Date.now.addingTimeInterval(minimumDisplayTime / .seconds(1)) + } else { + if let canHideAt, !canHideAt.hasPast { + self.state = .awaitingHiding(until: canHideAt) + } else { + self.state = .mustBeHidden + } + } } } } @@ -68,6 +87,9 @@ struct OverlayProgressView: View { } #Preview { + + @Previewable @State var shouldDisplay: Bool = false + NavigationStack { List { ForEach(0..<12) { i in @@ -75,8 +97,15 @@ struct OverlayProgressView: View { } } .navigationTitle("Demo") + .toolbar { + Button { + shouldDisplay.toggle() + } label: { + Text("Toggle Progress View") + } + } } .overlay(alignment: .top) { - OverlayProgressView(shouldBeVisible: true) + OverlayProgressView(shouldBeVisible: shouldDisplay) } } diff --git a/Modules/Sources/Support/UI/Support Conversations/ApplicationLogPicker.swift b/Modules/Sources/Support/UI/Support Conversations/ApplicationLogPicker.swift index 83e6bb3234a3..d2ed06c9b116 100644 --- a/Modules/Sources/Support/UI/Support Conversations/ApplicationLogPicker.swift +++ b/Modules/Sources/Support/UI/Support Conversations/ApplicationLogPicker.swift @@ -2,10 +2,10 @@ import SwiftUI struct ApplicationLogPicker: View { - enum ViewState { + enum ViewState: Equatable { case loading case loaded([ApplicationLog]) - case error(Error) + case error(String) } @EnvironmentObject @@ -50,7 +50,7 @@ struct ApplicationLogPicker: View { case .error(let error): ErrorView( title: Localization.unableToLoadApplicationLogs, - message: error.localizedDescription + message: error ) } } @@ -64,7 +64,7 @@ struct ApplicationLogPicker: View { let logs = try await dataProvider.fetchApplicationLogs() self.state = .loaded(logs) } catch { - self.state = .error(error) + self.state = .error(error.localizedDescription) } } diff --git a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift new file mode 100644 index 000000000000..5663124c4c4c --- /dev/null +++ b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift @@ -0,0 +1,274 @@ +import SwiftUI +import AsyncImageKit +import PDFKit +import AVKit + +struct SingleImageView: View { + + let url: URL + + @GestureState private var currentZoom = 1.0 + + var magnification: some Gesture { + MagnifyGesture().updating($currentZoom, body: { newValue, state, transaction in + state = newValue.magnification + }) + } + + var body: some View { + CachedAsyncImage(url: url) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + .scaleEffect(currentZoom) + .scaledToFit() + .gesture(magnification) + } placeholder: { + ProgressView(Localization.loadingImage) + } + .navigationTitle(url.lastPathComponent) + } +} + +struct SingleVideoView: View { + + @State + private var player: AVPlayer? = nil + + @State + private var error: Error? = nil + + private let url: URL + private let host: MediaHostProtocol? + + init(url: URL, host: MediaHostProtocol? = nil) { + self.url = url + self.host = host + } + + var body: some View { + Group { + if let player { + VideoPlayer(player: player) + .ignoresSafeArea() + .onAppear { + player.play() + } + } else if let error { + FullScreenErrorView( + title: Localization.unableToDisplayVideo, + message: error.localizedDescription, + systemImage: "film" + ) + } else { + FullScreenProgressView(Localization.loadingVideo) + } + }.task { + if let host { + do { + let asset = try await host.authenticatedAsset(for: url) + self.player = AVPlayer(playerItem: AVPlayerItem(asset: asset)) + } catch { + self.error = error + } + } else { + self.player = AVPlayer(url: url) + } + } + } +} + +struct SinglePDFView: UIViewRepresentable { + let url: URL // Or Data for in-memory PDFs + + func makeUIView(context: Context) -> PDFView { + let pdfView = PDFView() + if let document = PDFDocument(url: url) { + pdfView.document = document + } + return pdfView + } + + func updateUIView(_ uiView: PDFView, context: Context) { + // Update the view if the URL or other properties change + if let document = PDFDocument(url: url) { + uiView.document = document + } + } +} + +struct AttachmentListView: View { + let attachments: [Attachment] + + @State private var selectedAttachment: Attachment? + + private let columns = [ + GridItem(.adaptive(minimum: 80, maximum: 120), spacing: 8) + ] + + private var imageAttachments: [Attachment] { + attachments.filter { $0.isImage || $0.isVideo } + } + + private var otherAttachments: [Attachment] { + attachments.filter { !$0.isImage && !$0.isVideo } + } + + var body: some View { + VStack(alignment: .leading) { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(imageAttachments) { attachment in + AttachmentThumbnailView(attachment: attachment) + } + } + + ForEach(otherAttachments) { attachment in + AttachmentRowView(attachment: attachment) + } + } + } +} + +struct AttachmentThumbnailView: View { + + @EnvironmentObject + private var supportDataProvider: SupportDataProvider + + let attachment: Attachment + + var body: some View { + NavigationLink { + if attachment.isImage { + SingleImageView(url: attachment.url) + } + + if attachment.isVideo { + SingleVideoView(url: attachment.url, host: supportDataProvider.mediaHost) + } + } label: { + ZStack { + if attachment.isImage { + CachedAsyncImage(url: attachment.url, host: supportDataProvider.mediaHost, mutability: .immutable) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Color.gray.opacity(0.2).overlay { + ProgressView() + } + + } + } + + if attachment.isVideo { + CachedAsyncImage( + videoUrl: attachment.url, + host: supportDataProvider.mediaHost, + mutability: .immutable + ) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .overlay { + Image(systemName: "play.circle") + .foregroundStyle(Color.white) + } + } placeholder: { + Color.gray.opacity(0.2).overlay { + ProgressView() + } + } + } + } + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } +} + +struct AttachmentRowView: View { + + let attachment: Attachment + + var body: some View { + NavigationLink { + if attachment.isPdf { + SinglePDFView(url: attachment.url) + .navigationTitle(attachment.filename) + } + } label: { + HStack(alignment: .firstTextBaseline) { + Image(systemName: attachment.icon) + .foregroundColor(.secondary) + .font(.body) + .frame(width: 40, height: 40) + Text(attachment.filename) + .font(.body) + .foregroundColor(.secondary) + .lineLimit(1) + Spacer() + } + .background(Color(UIColor.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.bottom, 4) + } + } +} + +typealias ImageUrl = String + +extension ImageUrl: @retroactive Identifiable { + public var id: String { + self + } + + var filename: String { + self.url.lastPathComponent + } + + var url: URL { + URL(string: self)! + } +} + +#Preview { + + let images = [ + "https://picsum.photos/seed/1/800/600", + "https://picsum.photos/seed/2/800/600", + "https://picsum.photos/seed/3/800/600", + "https://picsum.photos/seed/4/800/600", + "https://picsum.photos/seed/5/800/600", + ].map { ImageUrl($0) }.map { Attachment( + id: .random(in: 0...UInt64.max), + filename: $0.filename, + contentType: "image/jpeg", + fileSize: 123456, + url: $0.url + ) } + + let documents = [ + "https://www.rd.usda.gov/sites/default/files/pdf-sample_0.pdf" + ].map { ImageUrl($0) }.map { Attachment( + id: .random(in: 0...UInt64.max), + filename: $0.filename, + contentType: "application/pdf", + fileSize: 45678, + url: $0.url + )} + + let videos = [ + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4" + ].map { ImageUrl($0) }.map { Attachment( + id: .random(in: 0...UInt64.max), + filename: "file_example_MP4_1920_18MG.mp4", + contentType: "video/mp4", + fileSize: 99842342, + url: $0.url + )} + + NavigationStack { + AttachmentListView(attachments: images + documents + videos) + }.environmentObject(SupportDataProvider.testing) +} diff --git a/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift b/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift index b8647e411656..ccb3857e721e 100644 --- a/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift +++ b/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift @@ -3,7 +3,23 @@ import PhotosUI struct ScreenshotPicker: View { - private let maxScreenshots = 5 + enum ViewState: Sendable { + case ready + case loading + case error(Error) + + var isLoadingMoreImages: Bool { + guard case .loading = self else { return false } + return true + } + + var error: Error? { + guard case .error(let error) = self else { return nil } + return error + } + } + + private let maxScreenshots = 10 @State private var selectedPhotos: [PhotosPickerItem] = [] @@ -12,78 +28,70 @@ struct ScreenshotPicker: View { private var attachedImages: [UIImage] = [] @State - private var error: Error? + private var state: ViewState = .ready @Binding var attachedImageUrls: [URL] + @State + private var currentUploadSize: CGFloat = 0 + + let maximumUploadSize: CGFloat? + + @Binding + var uploadLimitExceeded: Bool + var body: some View { Section { - 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) - } - } + Text(Localization.screenshotsDescription) + .font(.body) + .foregroundColor(.secondary) - if let error { + if let error = self.state.error { ErrorView( title: "Unable to load screenshot", message: error.localizedDescription - ).frame(maxWidth: .infinity) + ) + } + + if !attachedImages.isEmpty { + imageGallery + maxSizeIndicator } // Add screenshots button PhotosPicker( selection: $selectedPhotos, maxSelectionCount: maxScreenshots, - matching: .images - ) { [imageCount = attachedImages.count] in + matching: .any(of: [ + .screenshots, + .screenRecordings + ]) + ) { [imageCount = attachedImages.count, isLoading = self.state.isLoadingMoreImages, uploadLimitExceeded = self.uploadLimitExceeded] in HStack { - Image(systemName: "camera.fill") + if isLoading { + ProgressView() + .tint(Color.accentColor) + } else { + Image(systemName: "camera.fill") + } + Text(imageCount == 0 ? Localization.addScreenshots : Localization.addMoreScreenshots) } .frame(maxWidth: .infinity) .padding() .background(Color.accentColor.opacity(0.1)) - .foregroundColor(Color.accentColor) + .foregroundStyle(uploadLimitExceeded ? Color.gray : Color.accentColor) .cornerRadius(8) } .onChange(of: selectedPhotos) { _, newItems in Task { + self.state = .loading await loadSelectedPhotos(newItems) + self.state = .ready } } - } + .disabled(uploadLimitExceeded) } header: { HStack { Text(Localization.screenshots) @@ -92,12 +100,66 @@ struct ScreenshotPicker: View { .foregroundColor(.secondary) } } + .listRowSeparator(.hidden) + .selectionDisabled() + } + + @ViewBuilder + var imageGallery: some View { + // Screenshots display + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 12) { + ForEach(Array(attachedImages.enumerated()), id: \.offset) { index, image in + ZStack(alignment: .topTrailing) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 80, height: 80) + .clipped() + .cornerRadius(8) + + // Remove button + Button { + // attachedImages will be updated by changing `selectedPhotos`, but not immediately. This line is here to make the UI feel snappy + attachedImages.remove(at: index) + selectedPhotos.remove(at: index) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + .background(Color.white, in: Circle()) + } + .padding(4) + } + } + } + .padding(.horizontal, 2) + } + } + + @ViewBuilder + var maxSizeIndicator: some View { + if let maximumUploadSize { + VStack(alignment: .leading) { + ProgressView(value: currentUploadSize, total: maximumUploadSize) + .tint(uploadLimitExceeded ? Color.red : Color.accentColor) + + Text(String.localizedStringWithFormat(Localization.attachmentLimit, format(bytes: currentUploadSize), format(bytes: maximumUploadSize))) + .font(.caption2) + .foregroundStyle(Color.secondary) + } + } + } + + private func format(bytes: CGFloat) -> String { + ByteCountFormatter().string(fromByteCount: Int64(bytes)) } /// Loads selected photos from PhotosPicker + @MainActor func loadSelectedPhotos(_ items: [PhotosPickerItem]) async { var newImages: [UIImage] = [] var newUrls: [URL] = [] + var totalSize: CGFloat = 0 do { for item in items { @@ -105,6 +167,8 @@ struct ScreenshotPicker: View { if let image = UIImage(data: data) { newImages.append(image) } + + totalSize += CGFloat(data.count) } if let file = try await item.loadTransferable(type: ScreenshotFile.self) { @@ -112,15 +176,16 @@ struct ScreenshotPicker: View { } } - await MainActor.run { - attachedImages = newImages - attachedImageUrls = newUrls + self.attachedImages = newImages + self.attachedImageUrls = newUrls + + withAnimation { + self.currentUploadSize = totalSize + self.uploadLimitExceeded = totalSize > maximumUploadSize ?? .infinity } } catch { - await MainActor.run { - withAnimation { - self.error = error - } + withAnimation { + self.state = .error(error) } } } @@ -162,7 +227,11 @@ struct ScreenshotFile: Transferable { var body: some View { Form { - ScreenshotPicker(attachedImageUrls: $selectedPhotoUrls) + ScreenshotPicker( + attachedImageUrls: $selectedPhotoUrls, + maximumUploadSize: 10_000_000, + uploadLimitExceeded: .constant(false) + ) } .environmentObject(SupportDataProvider.testing) } diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift index 3d27142c7305..56ab45510b57 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift @@ -1,27 +1,14 @@ import SwiftUI +@MainActor public struct SupportConversationListView: View { enum ViewState: Equatable { - case loading - case partiallyLoaded([ConversationSummary]) + case start + case loading(Task) + case partiallyLoaded([ConversationSummary], Task) 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 - } - } + case error(String) var isPartiallyLoaded: Bool { guard case .partiallyLoaded = self else { @@ -36,7 +23,7 @@ public struct SupportConversationListView: View { private var dataProvider: SupportDataProvider @State - private var state: ViewState = .loading + private var state: ViewState = .start @State private var isComposingNewMessage: Bool = false @@ -50,14 +37,14 @@ public struct SupportConversationListView: View { public var body: some View { Group { switch self.state { - case .loading: - ProgressView(Localization.loadingConversations) - case .partiallyLoaded(let conversations), .loaded(let conversations): + case .start, .loading: + FullScreenProgressView(Localization.loadingConversations) + case .partiallyLoaded(let conversations, _), .loaded(let conversations): self.conversationsList(conversations) case .error(let error): - ErrorView( + FullScreenErrorView( title: Localization.errorLoadingSupportConversations, - message: error.localizedDescription + message: error ) } } @@ -75,7 +62,9 @@ public struct SupportConversationListView: View { } .sheet(isPresented: self.$isComposingNewMessage, content: { NavigationStack { - SupportForm(supportIdentity: self.currentUser) + SupportForm(supportIdentity: self.currentUser) { + self.reloadConversations() + } }.environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller }) .overlay { @@ -108,38 +97,49 @@ public struct SupportConversationListView: View { .listRowSeparator(.hidden) } - private func loadConversations() async { - do { - let fetch = try dataProvider.loadSupportConversations() + @MainActor + private func loadConversations() { + guard case .start = self.state else { + return + } - if let cachedResults = try await fetch.cachedResult() { - await MainActor.run { - self.state = .partiallyLoaded(cachedResults) - } - } + self.state = .loading(self.cacheTask) + } - let fetchedResults = try await fetch.fetchedResult() + @MainActor + private func reloadConversations() { + guard case .loaded(let conversations) = state else { + return + } + + self.state = .partiallyLoaded(conversations, self.fetchTask) + } - await MainActor.run { + private var cacheTask: Task { + Task { + do { + let fetch = try dataProvider.loadSupportConversations() + + if let cachedResults = try await fetch.cachedResult() { + self.state = .partiallyLoaded(cachedResults, self.fetchTask) + } + + let fetchedResults = try await fetch.fetchedResult() self.state = .loaded(fetchedResults) - } - } catch { - await MainActor.run { - self.state = .error(error) + } catch { + self.state = .error(error.localizedDescription) } } } - private func reloadConversations() async { - do { - let conversations = try await dataProvider.loadSupportConversations().fetchedResult() - - await MainActor.run { + private var fetchTask: Task { + Task { + do { + let fetch = try dataProvider.loadSupportConversations() + let conversations = try await fetch.fetchedResult() self.state = .loaded(conversations) - } - } catch { - await MainActor.run { - self.state = .error(error) + } catch { + self.state = .error(error.localizedDescription) } } } @@ -147,10 +147,56 @@ public struct SupportConversationListView: View { // MARK: - Email Row View struct EmailRowView: View { + + @Environment(\.sizeCategory) + private var sizeCategory + let conversation: ConversationSummary var body: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading) { + VStack { + header + + HStack { + TimelineView(.periodic(from: .now, by: 1.0)) { context in + Text(formatTimestamp(conversation.lastMessageSentAt)) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + } + }.padding(.bottom, 2) + + Text(conversation.plainTextDescription) + .font(.body) + .foregroundColor(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + } + + @ViewBuilder + var header: some View { + if self.sizeCategory.isAccessibilityCategory { + VStack { + HStack { + Text(conversation.title) + .font(.headline) + .foregroundColor(.primary) + .lineLimit(2) + Spacer() + } + + HStack { + ChipView( + string: conversation.status.title, + color: conversation.status.color + ).controlSize(.mini) + Spacer() + } + } + } else { HStack { Text(conversation.title) .font(.headline) @@ -159,21 +205,12 @@ struct EmailRowView: View { 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) + ChipView( + string: conversation.status.title, + color: conversation.status.color + ).controlSize(.mini) + } } - .padding() - .background(Color.clear) } private func formatTimestamp(_ date: Date) -> String { diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift index a5e93d266a9b..252324bac31c 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift @@ -3,20 +3,43 @@ import PhotosUI public struct SupportConversationReplyView: View { + private let enableRichTextForm: Bool = false + 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 + case error(String) + + var isSendingMessage: Bool { + guard case .sending = self else { return false } + return true + } + + var messageWasSent: Bool { + guard case .sent = self else { return false } + return true + } + + var isError: Bool { + guard case .error = self else { return false } + return true + } + + var error: String { + guard case .error(let string) = self else { + return "" } + + return string + } + + var cancelButtonShouldBeDisabled: Bool { + if case .sending = self { + return true + } + + return false } } @@ -39,11 +62,14 @@ public struct SupportConversationReplyView: View { @State private var state: ViewState = .editing + @State + private var isDisplayingCancellationConfirmation: Bool = false + @FocusState private var isTextFieldFocused: Bool - @State - private var selectedPhotos: [URL] = [] + @State private var selectedPhotos: [URL] = [] + @State private var uploadLimitExceeded: Bool = false @State private var includeApplicationLogs: Bool = false @@ -54,7 +80,7 @@ public struct SupportConversationReplyView: View { } private var canSendMessage: Bool { - !textIsEmpty && state == .editing + !textIsEmpty && state == .editing && !uploadLimitExceeded } public init(conversation: Conversation, currentUser: SupportUser, conversationDidUpdate: @escaping (Conversation) -> Void) { @@ -71,7 +97,9 @@ public struct SupportConversationReplyView: View { } ScreenshotPicker( - attachedImageUrls: self.$selectedPhotos + attachedImageUrls: self.$selectedPhotos, + maximumUploadSize: self.dataProvider.maximumUploadSize, + uploadLimitExceeded: self.$uploadLimitExceeded ) ApplicationLogPicker( @@ -79,6 +107,8 @@ public struct SupportConversationReplyView: View { ) } } + .scrollDismissesKeyboard(.interactively) + .interactiveDismissDisabled(!self.textIsEmpty) // Don't allow swiping down to dismiss if the user would lose data .navigationTitle(Localization.reply) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -86,57 +116,85 @@ public struct SupportConversationReplyView: View { Button(Localization.cancel) { dismiss() } - .disabled({ - if case .sending = state { - return true - } - return false - }()) + .disabled(self.state.cancelButtonShouldBeDisabled) } ToolbarItem(placement: .confirmationAction) { Button { self.sendReply() } label: { - if case .sending = state { - HStack { - ProgressView() - .scaleEffect(0.8) - Text(Localization.sending) - } - } else { - Text(Localization.send) - } + Text(Localization.send) } .disabled(!canSendMessage) } } .overlay { - switch self.state { - case .error(let error): + ZStack { + ProgressView(Localization.sendingMessage) + .padding() + .background(Color(UIColor.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .shadow(radius: 8) + .opacity(state.isSendingMessage ? 1.0 : 0.0) + .offset(x: 0, y: state.isSendingMessage ? 0 : 20) + ErrorView( title: Localization.unableToSendMessage, - message: error.localizedDescription + message: state.error ) - case .sent: - ContentUnavailableView( - Localization.messageSent, - systemImage: "checkmark.circle", - description: nil - ).onTapGesture { + .padding() + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .shadow(radius: 8) + .opacity(state.isError ? 1.0 : 0.0) + .offset(x: 0, y: state.isError ? 0 : 20) + .onTapGesture { + self.state = .editing + } + + VStack { + HStack { + Image(systemName: "checkmark.circle") + .font(.system(size: 48)) + .foregroundStyle(Color.gray) + .padding(.top, -4) + .padding(.bottom, 4) + } + Text(Localization.messageSent).font(.title2).bold() + } + .padding() + .background(Color(UIColor.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .shadow(radius: 8) + .opacity(state.messageWasSent ? 1.0 : 0.0) + .offset(x: 0, y: state.messageWasSent ? 0 : 20) + .onTapGesture { self.dismiss() } - default: EmptyView() } } .onAppear { isTextFieldFocused = true } + .alert( + Localization.confirmCancellation, + isPresented: $isDisplayingCancellationConfirmation, + actions: { + Button(Localization.discardChanges, role: .destructive) { + self.dismiss() + } + + Button(Localization.continueWriting, role: .cancel) { + self.isDisplayingCancellationConfirmation = false + } + }, message: { + Text(Localization.confirmCancelMessage) + } + ) } @ViewBuilder var textEditor: some View { - if #available(iOS 26.0, *) { + if #available(iOS 26.0, *), enableRichTextForm { TextEditor(text: $richText) .focused($isTextFieldFocused) .clipShape(RoundedRectangle(cornerRadius: 8)) @@ -152,7 +210,7 @@ public struct SupportConversationReplyView: View { } private func getText() throws -> String { - if #available(iOS 26.0, *) { + if #available(iOS 26.0, *), enableRichTextForm { return self.richText.toHtml() } else { return self.plainText.trimmingCharacters(in: .whitespacesAndNewlines) @@ -162,7 +220,13 @@ public struct SupportConversationReplyView: View { private func sendReply() { guard !textIsEmpty else { return } - let task = Task { + withAnimation { + state = .sending(self.sendingTask) + } + } + + var sendingTask: Task { + Task { do { let text = try getText() @@ -180,25 +244,17 @@ public struct SupportConversationReplyView: View { // Display the sent message for 2 seconds, then auto-dismiss try? await Task.sleep(for: .seconds(2)) - await MainActor.run { - dismiss() - } + dismiss() }) } } catch { - state = .error(error) + state = .error(error.localizedDescription) - // 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 { @@ -211,9 +267,15 @@ public struct SupportConversationReplyView: View { // MARK: - Application Log Row Component #Preview { + + @Previewable @State + var isPresented: Bool = true + NavigationStack { - Text("Hello World") - }.sheet(isPresented: .constant(true)) { + Text("Hello World").onTapGesture { + isPresented = true + } + }.sheet(isPresented: $isPresented) { NavigationStack { SupportConversationReplyView( conversation: SupportDataProvider.supportConversation, diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift index 6634f227547a..cda07720ef86 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift @@ -1,12 +1,14 @@ import SwiftUI +import AsyncImageKit public struct SupportConversationView: View { - enum ViewState { - case loading - case partiallyLoaded(Conversation) + enum ViewState: Equatable { + case start + case loading(cacheLoadTask: Task) + case partiallyLoaded(Conversation, fetchTask: Task) case loaded(Conversation) - case error(Error) + case error(String) var isPartiallyLoaded: Bool { guard case .partiallyLoaded = self else { @@ -15,17 +17,34 @@ public struct SupportConversationView: View { return true } + + var conversation: Conversation? { + switch self { + case .start: nil + case .loading: nil + case .partiallyLoaded(let conversation, _): conversation + case .loaded(let conversation): conversation + case .error: nil + } + } + + var canAcceptReply: Bool { + conversation?.canAcceptReply ?? false + } } @EnvironmentObject private var dataProvider: SupportDataProvider @State - private var state: ViewState + private var state: ViewState = .start @State private var isReplying: Bool = false + @Namespace + var bottom + private let conversationSummary: ConversationSummary private let currentUser: SupportUser @@ -36,18 +55,18 @@ public struct SupportConversationView: View { return false } - if case .loaded = state { - return true + // Only allow replying once the conversation is fully loaded + guard case .loaded(let conversation) = state else { + return false } - return false + return conversation.canAcceptReply } public init( conversation: ConversationSummary, currentUser: SupportUser ) { - self.state = .loading self.currentUser = currentUser self.conversationSummary = conversation } @@ -55,29 +74,30 @@ public struct SupportConversationView: View { public var body: some View { VStack(spacing: 0) { switch self.state { - case .loading: - ProgressView(Localization.loadingMessages) - case .partiallyLoaded(let conversation): - self.conversationView(conversation) - case .loaded(let conversation): + case .start, .loading: + FullScreenProgressView(Localization.loadingMessages) + case .partiallyLoaded(let conversation, _), .loaded(let conversation): self.conversationView(conversation) case .error(let error): - ErrorView( + FullScreenErrorView( title: Localization.unableToDisplayConversation, - message: error.localizedDescription + message: error ) } } + .task(self.loadConversation) .navigationTitle(self.conversationSummary.title) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - Button { - self.isReplying = true - } label: { - Image(systemName: "arrowshape.turn.up.left") + if self.state.canAcceptReply { + ToolbarItemGroup(placement: .primaryAction) { + Button { + self.isReplying = true + } label: { + Image(systemName: "arrowshape.turn.up.left") + } + .disabled(!canReply) } - .disabled(!canReply) } } .overlay { @@ -91,7 +111,7 @@ public struct SupportConversationView: View { currentUser: currentUser, conversationDidUpdate: { conversation in withAnimation { - self.state = .loaded(conversation) + self.state = .partiallyLoaded(conversation, fetchTask: self.fetchTask) } } ) @@ -102,8 +122,6 @@ public struct SupportConversationView: View { .onAppear { self.dataProvider.userDid(.viewSupportTicket(ticketId: conversationSummary.id)) } - .task(self.loadConversation) - .refreshable(action: self.reloadConversation) } @ViewBuilder @@ -122,19 +140,31 @@ public struct SupportConversationView: View { message: message ) } - Button { - self.isReplying = true - } label: { - Spacer() - HStack(alignment: .firstTextBaseline) { - Image(systemName: "arrowshape.turn.up.left") - Text(Localization.reply) - }.padding(.vertical, 8) - Spacer() + + if conversation.canAcceptReply { + Button { + self.isReplying = true + } label: { + Spacer() + HStack(alignment: .firstTextBaseline) { + Image(systemName: "arrowshape.turn.up.left") + Text(Localization.reply) + }.padding(.vertical, 8) + Spacer() + } + .padding() + .buttonStyle(BorderedProminentButtonStyle()) + .disabled(!canReply) + } else { + Text(Localization.conversationEnded) + .font(.caption) + .foregroundStyle(Color.secondary) + .padding(.top) } - .padding() - .buttonStyle(BorderedProminentButtonStyle()) - .disabled(!canReply) + + Divider() + .opacity(0) + .id(self.bottom) } } .background(Color(UIColor.systemGroupedBackground)) @@ -144,6 +174,7 @@ public struct SupportConversationView: View { .onChange(of: conversation.messages.count) { scrollToBottom(proxy: proxy) } + .refreshable(action: self.reloadConversation) } } @@ -151,12 +182,10 @@ public struct SupportConversationView: View { 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) + ChipView( + string: conversation.status.title, + color: conversation.status.color + ).controlSize(.small) Spacer() @@ -170,15 +199,14 @@ public struct SupportConversationView: View { .padding() } + @MainActor private func scrollToBottom(proxy: ScrollViewProxy) { - guard case .loaded(let conversation) = state else { + guard case .loaded = state else { return } - if let lastMessage = conversation.messages.last { - withAnimation(.easeInOut(duration: 0.3)) { - proxy.scrollTo(lastMessage.id, anchor: .bottom) - } + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(self.bottom, anchor: .bottom) } } @@ -197,43 +225,47 @@ public struct SupportConversationView: View { return formatter.localizedString(for: date, relativeTo: Date()) } + @MainActor private func loadConversation() async { - do { - let conversationId = self.conversationSummary.id - - let fetch = try self.dataProvider.loadSupportConversation(id: conversationId) - - if let cached = try await fetch.cachedResult() { - await MainActor.run { - self.state = .partiallyLoaded(cached) - } - } - - let conversation = try await fetch.fetchedResult() - await MainActor.run { - self.state = .loaded(conversation) - } - } catch { - self.state = .error(error) + guard case .start = state else { + return } + + self.state = .loading(cacheLoadTask: self.cacheTask) } + @MainActor private func reloadConversation() async { guard case .loaded(let conversation) = state else { return } - do { - await MainActor.run { - self.state = .partiallyLoaded(conversation) - } + self.state = .partiallyLoaded(conversation, fetchTask: fetchTask) + } - let conversation = try await self.dataProvider.loadSupportConversation(id: conversation.id).fetchedResult() + private var cacheTask: Task { + Task { + do { + let id = self.conversationSummary.id + if let conversation = try await self.dataProvider.loadSupportConversation(id: id).cachedResult() { + self.state = .partiallyLoaded(conversation, fetchTask: self.fetchTask) + } else { + await self.fetchTask.value + } + } catch { + self.state = .error(error.localizedDescription) + } + } + } - self.state = .loaded(conversation) - } catch { - await MainActor.run { - self.state = .error(error) + private var fetchTask: Task { + Task { + do { + let id = self.conversationSummary.id + let conversation = try await self.dataProvider.loadSupportConversation(id: id).fetchedResult() + self.state = .loaded(conversation) + } catch { + self.state = .error(error.localizedDescription) } } } @@ -279,35 +311,6 @@ struct MessageRowView: View { } } -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 { NavigationStack { SupportConversationView( diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift index 6a7be9f8e00c..2befa8f95ef9 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift @@ -4,9 +4,14 @@ import PhotosUI public struct SupportForm: View { + private let enableRichTextForm: Bool = false + @EnvironmentObject private var dataProvider: SupportDataProvider + @Environment(\.dismiss) + private var dismiss + /// Focus state for managing field focus @FocusState private var focusedField: Field? @@ -41,16 +46,22 @@ public struct SupportForm: View { @State private var applicationLogs: [ApplicationLog] @State private var selectedPhotos: [URL] = [] + @State private var uploadLimitExceeded = false /// UI State @State private var showLoadingIndicator = false @State private var shouldShowErrorAlert = false @State private var shouldShowSuccessAlert = false @State private var errorMessage = "" + @State private var isDisplayingCancellationConfirmation: Bool = false /// Callback for when form is dismissed public var onDismiss: (() -> Void)? + private var subjectIsEmpty: Bool { + subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + private var problemDescriptionIsEmpty: Bool { plainTextProblemDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && NSAttributedString(attributedProblemDescription).string @@ -61,14 +72,21 @@ public struct SupportForm: View { /// Determines if the submit button should be enabled or not. private var submitButtonDisabled: Bool { selectedArea == nil - || subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || subjectIsEmpty || problemDescriptionIsEmpty + || uploadLimitExceeded + } + + /// Determines if the user has unsaved changes – if they do, we won't allow dismissing the form + /// without prompting the user first. + private var userHasUnsavedChanges: Bool { + !subjectIsEmpty || !problemDescriptionIsEmpty } public init( - onDismiss: (() -> Void)? = nil, supportIdentity: SupportUser, - applicationLogs: [ApplicationLog] = [] + applicationLogs: [ApplicationLog] = [], + onDismiss: (() -> Void)? = nil ) { self.onDismiss = onDismiss self.supportIdentity = supportIdentity @@ -85,7 +103,9 @@ public struct SupportForm: View { // Screenshots Section ScreenshotPicker( - attachedImageUrls: $selectedPhotos + attachedImageUrls: $selectedPhotos, + maximumUploadSize: self.dataProvider.maximumUploadSize, + uploadLimitExceeded: self.$uploadLimitExceeded ) // Application Logs Section @@ -99,8 +119,37 @@ public struct SupportForm: View { // Submit Button Section submitButtonSection } + .scrollDismissesKeyboard(.interactively) + .interactiveDismissDisabled(self.userHasUnsavedChanges) .navigationTitle(Localization.title) .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(Localization.cancel, role: .cancel) { + if self.userHasUnsavedChanges { + self.isDisplayingCancellationConfirmation = true + } else { + self.onDismiss?() + self.dismiss() + } + } + } + } + .alert( + Localization.confirmCancellation, + isPresented: $isDisplayingCancellationConfirmation, + actions: { + Button(Localization.discardChanges, role: .destructive) { + self.dismiss() + } + + Button(Localization.continueWriting, role: .cancel) { + self.isDisplayingCancellationConfirmation = false + } + }, message: { + Text(Localization.confirmCancelMessage) + } + ) .alert(Localization.errorTitle, isPresented: $shouldShowErrorAlert) { Button(Localization.gotIt) { shouldShowErrorAlert = false @@ -112,6 +161,7 @@ public struct SupportForm: View { Button(Localization.gotIt) { shouldShowSuccessAlert = false onDismiss?() + self.dismiss() } } message: { Text(Localization.supportRequestSentMessage) @@ -150,7 +200,7 @@ private extension SupportForm { var contactInformationSection: some View { Section { VStack(alignment: .leading) { - Text("We'll email you at this address.") + Text(Localization.emailNotice) .font(.caption) .foregroundColor(.secondary) @@ -199,7 +249,7 @@ private extension SupportForm { @ViewBuilder var textEditor: some View { - if #available(iOS 26.0, *) { + if #available(iOS 26.0, *), self.enableRichTextForm { TextEditor(text: $attributedProblemDescription) .focused($focusedField, equals: .problemDescription) .clipShape(RoundedRectangle(cornerRadius: 8)) @@ -254,7 +304,7 @@ private extension SupportForm { } private func getText() throws -> String { - if #available(iOS 26.0, *) { + if #available(iOS 26.0, *), self.enableRichTextForm { return self.attributedProblemDescription.toHtml() } else { return self.plainTextProblemDescription.trimmingCharacters(in: .whitespacesAndNewlines) @@ -262,6 +312,7 @@ private extension SupportForm { } /// Submits the support request + @MainActor func submitSupportRequest() { guard !submitButtonDisabled else { return } @@ -273,19 +324,15 @@ private extension SupportForm { subject: self.subject, message: self.getText(), user: self.supportIdentity, - attachments: [] + attachments: self.selectedPhotos ) - await MainActor.run { - showLoadingIndicator = false - shouldShowSuccessAlert = true - } + showLoadingIndicator = false + shouldShowSuccessAlert = true } catch { - await MainActor.run { - showLoadingIndicator = false - errorMessage = error.localizedDescription - shouldShowErrorAlert = true - } + showLoadingIndicator = false + errorMessage = error.localizedDescription + shouldShowErrorAlert = true } } } @@ -376,10 +423,15 @@ private extension SupportFormArea { // MARK: - Previews #Preview { NavigationStack { - SupportForm( - supportIdentity: SupportDataProvider.supportUser, - applicationLogs: [SupportDataProvider.applicationLog] - ) + Text("Support Form") + } + .sheet(isPresented: .constant(true)) { + NavigationStack { + SupportForm( + supportIdentity: SupportDataProvider.supportUser, + applicationLogs: [SupportDataProvider.applicationLog] + ) + } } .toolbar { ToolbarItem(placement: .topBarTrailing) { diff --git a/Modules/Sources/WordPressCore/CacheResult.swift b/Modules/Sources/WordPressCore/CacheResult.swift new file mode 100644 index 000000000000..f2af89d5edff --- /dev/null +++ b/Modules/Sources/WordPressCore/CacheResult.swift @@ -0,0 +1,35 @@ +import Foundation + +public struct DiskCachedResult { + + public typealias Computation = @Sendable () async throws -> T where T: Codable & Sendable + + private let computationBlock: Computation + private let cacheKey: String + + public init( + computedResult: @escaping @Sendable () async throws -> T, + cacheKey: String + ) { + self.computationBlock = computedResult + self.cacheKey = cacheKey + } + + public func get() async throws -> T { + if let cachedValue = try await DiskCache.shared.read(T.self, forKey: self.cacheKey) { + return cachedValue + } + + let computedValue = try await computationBlock() + try await DiskCache.shared.store(computedValue, forKey: self.cacheKey) + + return computedValue + } +} + +public func cacheOnDisk( + key: String, + computation: @escaping DiskCachedResult.Computation +) async throws -> T where T: Codable & Sendable { + try await DiskCachedResult(computedResult: computation, cacheKey: key).get() +} diff --git a/Modules/Sources/WordPressCore/CachedAndFetchedResult.swift b/Modules/Sources/WordPressCore/CachedAndFetchedResult.swift index 062c74295296..6d94b0031d0c 100644 --- a/Modules/Sources/WordPressCore/CachedAndFetchedResult.swift +++ b/Modules/Sources/WordPressCore/CachedAndFetchedResult.swift @@ -1,24 +1,5 @@ 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 - } -} +import WordPressCoreProtocols /// 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). /// @@ -47,7 +28,7 @@ public struct DiskCachedAndFetchedResult: CachedAndFetchedResult where T: Cod public func fetchAndCache() async throws -> T { let result = try await userProvidedFetchBlock() - try await DiskCache().store(result, forKey: self.cacheKey) + try await DiskCache.shared.store(result, forKey: self.cacheKey) return result } diff --git a/Modules/Sources/WordPressCore/DiskCache.swift b/Modules/Sources/WordPressCore/DiskCache.swift index 61ddab5d23b0..bad897099e60 100644 --- a/Modules/Sources/WordPressCore/DiskCache.swift +++ b/Modules/Sources/WordPressCore/DiskCache.swift @@ -1,37 +1,41 @@ import Foundation +import WordPressCoreProtocols /// A super-basic on-disk cache for `Codable` objects. /// -public actor DiskCache { +public actor DiskCache: DiskCacheProtocol { - public struct DiskCacheUsage: Sendable, Equatable { - public let fileCount: Int - public let byteCount: Int64 - - public var diskUsage: Measurement { - Measurement(value: Double(byteCount), unit: .bytes) - } - - public var formattedDiskUsage: String { - return diskUsage.formatted(.byteCount(style: .file, allowedUnits: [.mb, .gb], spellsOutZero: true)) - } - - public var isEmpty: Bool { - fileCount == 0 - } - } + public static let shared = DiskCache() private let cacheRoot: URL = URL.cachesDirectory public init() {} - public func read(_ type: T.Type, forKey key: String) throws -> T? where T: Decodable { + public func read( + _ type: T.Type, + forKey key: String, + notOlderThan interval: TimeInterval? = nil + ) throws -> T? where T: Decodable { let path = self.path(forKey: key) guard FileManager.default.fileExists(at: path) else { return nil } + if let interval { + let attributes = try FileManager.default.attributesOfItem(atPath: path.path()) + + // If we can't find the creation date, assume the cache object is invalid because we can't guarantee + // the developer's intent will be respected. + guard let creationDate = attributes[.creationDate] as? Date else { + return nil + } + + if creationDate.addingTimeInterval(interval) > Date.now { + 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 @@ -52,16 +56,16 @@ public actor DiskCache { try FileManager.default.removeItem(at: self.path(forKey: key)) } - public func removeAll(progress: (@Sendable (Int, Int) async throws -> Void)? = nil) async throws { + public func removeAll(progress: (@Sendable (CacheDeletionProgress) async throws -> Void)? = nil) async throws { let files = try await fetchCacheEntries() let count = files.count - try await progress?(0, count) + try await progress?(CacheDeletionProgress(filesDeleted: 0, totalFileCount: count)) for file in files.enumerated() { try FileManager.default.removeItem(at: file.element) - try await progress?(file.offset + 1, count) + try await progress?(CacheDeletionProgress(filesDeleted: file.offset + 1, totalFileCount: count)) } } diff --git a/Modules/Sources/WordPressCoreProtocols/CachedAndFetchedResult.swift b/Modules/Sources/WordPressCoreProtocols/CachedAndFetchedResult.swift new file mode 100644 index 000000000000..2d802c0c1c0b --- /dev/null +++ b/Modules/Sources/WordPressCoreProtocols/CachedAndFetchedResult.swift @@ -0,0 +1,21 @@ +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 + } +} diff --git a/Modules/Sources/WordPressCoreProtocols/DiskCacheProtocol.swift b/Modules/Sources/WordPressCoreProtocols/DiskCacheProtocol.swift new file mode 100644 index 000000000000..5253c10e06af --- /dev/null +++ b/Modules/Sources/WordPressCoreProtocols/DiskCacheProtocol.swift @@ -0,0 +1,59 @@ +import Foundation + +public protocol DiskCacheProtocol: Actor { + func read( + _ type: T.Type, + forKey key: String, + notOlderThan interval: TimeInterval? + ) throws -> T? where T: Decodable + + func store(_ value: T, forKey key: String) throws where T: Encodable + + func remove(key: String) throws + + func removeAll(progress: (@Sendable (CacheDeletionProgress) async throws -> Void)?) async throws + + func count() async throws -> Int + + func diskUsage() async throws -> DiskCacheUsage +} + +public struct CacheDeletionProgress: Sendable, Equatable { + public let filesDeleted: Int + public let totalFileCount: Int + + public var progress: Double { + if filesDeleted > 0 && totalFileCount > 0 { + return Double(filesDeleted) / Double(totalFileCount) + } + + return 0 + } + + public init(filesDeleted: Int, totalFileCount: Int) { + self.filesDeleted = filesDeleted + self.totalFileCount = totalFileCount + } +} + +public struct DiskCacheUsage: Sendable, Equatable { + public let fileCount: Int + public let byteCount: Int64 + + public init(fileCount: Int, byteCount: Int64) { + self.fileCount = fileCount + self.byteCount = byteCount + } + + public var diskUsage: Measurement { + Measurement(value: Double(byteCount), unit: .bytes) + } + + public var formattedDiskUsage: String { + return diskUsage.formatted(.byteCount(style: .file, allowedUnits: [.mb, .gb], spellsOutZero: true)) + } + + public var isEmpty: Bool { + fileCount == 0 + } +} diff --git a/Modules/Sources/WordPressCoreProtocols/Extensions/Foundation+AsyncMap.swift b/Modules/Sources/WordPressCoreProtocols/Extensions/Foundation+AsyncMap.swift new file mode 100644 index 000000000000..af82adbdc1a9 --- /dev/null +++ b/Modules/Sources/WordPressCoreProtocols/Extensions/Foundation+AsyncMap.swift @@ -0,0 +1,14 @@ +import Foundation + +public extension Collection { + func asyncMap(operation: (Element) async throws -> T) async throws -> [T] { + var newCollection = [T]() + + for element in self { + let newElement = try await operation(element) + newCollection.append(newElement) + } + + return newCollection + } +} diff --git a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift b/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift index 7e55bcab7adf..66f386c49c9e 100644 --- a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift +++ b/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift @@ -122,6 +122,34 @@ public actor IntelligenceService { return session.streamResponse(to: prompt) } + public func summarizeSupportTicket(content: String) async throws -> String { + let instructions = """ + You are helping a user by summarizing their support request down to a single sentence + with fewer than 10 words. + + The summary should be clear, informative, and written in a neutral tone. + + Do not include anything other than the summary in the response. + """ + + let session = LanguageModelSession( + model: .init(guardrails: .permissiveContentTransformations), + instructions: instructions + ) + + let prompt = """ + Give me an appropriate conversation title for the following opening message of the conversation: + + \(content) + """ + + return try await session.respond( + to: prompt, + generating: SuggestedConversationTitle.self, + options: GenerationOptions(temperature: 1.0) + ).content.title + } + public nonisolated func extractRelevantText(from post: String, ratio: CGFloat = 0.6) -> String { let extract = try? IntelligenceUtilities.extractRelevantText(from: post) let postSizeLimit = Double(IntelligenceService.contextSizeLimit) * ratio @@ -142,3 +170,10 @@ private struct SuggestedTagsResult { @Guide(description: "Newly generated tags following the identified format") var tags: [String] } + +@available(iOS 26, *) +@Generable +private struct SuggestedConversationTitle { + @Guide(description: "The conversation title") + var title: String +} diff --git a/Modules/Sources/WordPressUI/Views/SiteIconView.swift b/Modules/Sources/WordPressUI/Views/SiteIconView.swift index 3d7cec0444bf..595c47cce09b 100644 --- a/Modules/Sources/WordPressUI/Views/SiteIconView.swift +++ b/Modules/Sources/WordPressUI/Views/SiteIconView.swift @@ -23,7 +23,7 @@ public struct SiteIconView: View { @ViewBuilder private var contents: some View { if let imageURL = viewModel.imageURL { - CachedAsyncImage(url: imageURL, host: viewModel.host) { phase in + CachedAsyncImage(url: imageURL, host: viewModel.host, content: { phase in switch phase { case .success(let image): image.resizable().aspectRatio(contentMode: .fit) @@ -32,7 +32,7 @@ public struct SiteIconView: View { default: backgroundColor } - } + }) } else { noIconView } diff --git a/WordPress/Classes/Networking/MediaHost.swift b/WordPress/Classes/Networking/MediaHost.swift index 3aad15939825..d21335900dbb 100644 --- a/WordPress/Classes/Networking/MediaHost.swift +++ b/WordPress/Classes/Networking/MediaHost.swift @@ -1,4 +1,5 @@ import Foundation +import AVFoundation import AsyncImageKit /// Defines a media host for request authentication purposes. @@ -97,4 +98,8 @@ public enum MediaHost: Equatable, Sendable, MediaHostProtocol { public func authenticatedRequest(for url: URL) async throws -> URLRequest { try await MediaRequestAuthenticator().authenticatedRequest(for: url, host: self) } + + public func authenticatedAsset(for url: URL) async throws -> AVURLAsset { + try await MediaRequestAuthenticator().authenticatedAsset(for: url, host: self) + } } diff --git a/WordPress/Classes/Networking/MediaRequestAuthenticator.swift b/WordPress/Classes/Networking/MediaRequestAuthenticator.swift index 9911aefa0308..852e8e5f68ce 100644 --- a/WordPress/Classes/Networking/MediaRequestAuthenticator.swift +++ b/WordPress/Classes/Networking/MediaRequestAuthenticator.swift @@ -1,5 +1,6 @@ import Foundation import AsyncImageKit +import AVFoundation import WordPressData fileprivate let photonHost = "i0.wp.com" @@ -105,6 +106,24 @@ struct MediaRequestAuthenticator { } } + func authenticatedAsset(for url: URL, host: MediaHost) async throws -> AVURLAsset { + switch host { + case .publicSite: AVURLAsset(url: url) + case .publicWPComSite: AVURLAsset(url: url) + case .privateSelfHostedSite: AVURLAsset(url: url) + case .privateWPComSite(let authToken): authenticatedAsset(for: url, authToken: authToken) + case .privateAtomicWPComSite(_, _, let authToken): authenticatedAsset(for: url, authToken: authToken) + } + } + + private func authenticatedAsset(for url: URL, authToken: String) -> AVURLAsset { + let headers: [String: String] = ["Authorization": "Bearer \(authToken)"] + + return AVURLAsset(url: url, options: [ + "AVURLAssetHTTPHeaderFieldsKey": headers + ]) + } + // MARK: - Request Authentication: Specific Scenarios /// Authentication for a WPCom private request. diff --git a/WordPress/Classes/Networking/WordPressDotComClient.swift b/WordPress/Classes/Networking/WordPressDotComClient.swift index 107b8981659f..8edd4c4ec5a0 100644 --- a/WordPress/Classes/Networking/WordPressDotComClient.swift +++ b/WordPress/Classes/Networking/WordPressDotComClient.swift @@ -1,25 +1,40 @@ import Foundation +import AsyncImageKit +import AVFoundation import WordPressAPI import WordPressAPIInternal import Combine +import OSLog -actor WordPressDotComClient { +actor WordPressDotComClient: MediaHostProtocol { + private let authProvider: AutoUpdatingWPComAuthenticationProvider + private let delegate: WpApiClientDelegate let api: WPComApiClient init() { let session = URLSession(configuration: .ephemeral) - let provider = AutoUpdatingWPComAuthenticationProvider(coreDataStack: ContextManager.shared) - let delegate = WpApiClientDelegate( - authProvider: .dynamic(dynamicAuthenticationProvider: provider), + self.authProvider = AutoUpdatingWPComAuthenticationProvider(coreDataStack: ContextManager.shared) + self.delegate = WpApiClientDelegate( + authProvider: .dynamic(dynamicAuthenticationProvider: self.authProvider), requestExecutor: WpRequestExecutor(urlSession: session), - middlewarePipeline: WpApiMiddlewarePipeline(middlewares: []), + middlewarePipeline: WpApiMiddlewarePipeline(middlewares: [ + WpComTrafficDebugger() + ]), appNotifier: WpComNotifier() ) self.api = WPComApiClient(delegate: delegate) } + + func authenticatedRequest(for url: URL) async throws -> URLRequest { + self.authProvider.authorize(URLRequest(url: url)) + } + + func authenticatedAsset(for url: URL) async throws -> AVURLAsset { + self.authProvider.authorize(AVURLAsset(url: url)) + } } final class AutoUpdatingWPComAuthenticationProvider: @unchecked Sendable, WpDynamicAuthenticationProvider { @@ -57,6 +72,47 @@ final class AutoUpdatingWPComAuthenticationProvider: @unchecked Sendable, WpDyna return authentication } + private var authorizationHeaderValue: String? { + switch self.authentication { + case .authorizationHeader(let headerValue): + headerValue + case .bearer(let token): + "Bearer \(token)" + default: nil + } + } + + func authorize(_ request: URLRequest) -> URLRequest { + var mutableRequest = request + + // Don't authorize requests for other domains + guard request.url?.host() == "public-api.wordpress.com" else { + return request + } + + mutableRequest.setValue(self.authorizationHeaderValue, forHTTPHeaderField: "Authorization") + + return mutableRequest + } + + func authorize(_ asset: AVURLAsset) -> AVURLAsset { + + // Don't authorize requests for other domains + guard asset.url.host() == "public-api.wordpress.com" else { + return asset + } + + guard let headerValue = self.authorizationHeaderValue else { + return asset + } + + let headers: [String: String] = ["Authorization": headerValue] + + return AVURLAsset(url: asset.url, options: [ + "AVURLAssetHTTPHeaderFieldsKey": headers + ]) + } + private static func readAuthentication(on stack: CoreDataStack) -> WpAuthentication { do { guard let authToken = try stack.performQuery({ @@ -94,3 +150,27 @@ final class WpComNotifier: WpAppNotifier { NotificationCenter.default.post(name: Self.notificationName, object: nil) } } + +final class WpComTrafficDebugger: Middleware { + func process( + requestExecutor: any WordPressAPIInternal.RequestExecutor, + response: WordPressAPIInternal.WpNetworkResponse, + request: WordPressAPIInternal.WpNetworkRequest, + context: WordPressAPIInternal.RequestContext? + ) async throws -> WordPressAPIInternal.WpNetworkResponse { + Logger.networking.debug("[\(request.method())] \(request.url())") + return response + } +} + +extension RequestMethod: @retroactive CustomStringConvertible { + public var description: String { + switch self { + case .get: "GET" + case .post: "POST" + case .put: "PUT" + case .delete: "DELETE" + case .head: "HEAD" + } + } +} diff --git a/WordPress/Classes/System/Logging.swift b/WordPress/Classes/System/Logging.swift new file mode 100644 index 000000000000..5b8fb9dfb0c1 --- /dev/null +++ b/WordPress/Classes/System/Logging.swift @@ -0,0 +1,9 @@ +import OSLog + +extension Logger { + /// Using your bundle identifier is a great way to ensure a unique identifier. + private static let subsystem = Bundle.main.bundleIdentifier! + + /// Logs the view cycles like a view that appeared. + static let networking = Logger(subsystem: subsystem, category: "network") +} diff --git a/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift index c2565c03f56d..d45c0482aa36 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift @@ -151,33 +151,23 @@ struct RootSupportView: View { // 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) - } + 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) - } + self.state = .loaded(user: fetchedIdentity) } catch { - await MainActor.run { - self.state = .error(error) - } + self.state = .error(error) } } @Sendable private func reloadIdentity() async { do { let fetchedIdentity = try await self.dataProvider.loadSupportIdentity().fetchedResult() - await MainActor.run { - self.state = .loaded(user: fetchedIdentity) - } + self.state = .loaded(user: fetchedIdentity) } catch { - await MainActor.run { - self.state = .error(error) - } + self.state = .error(error) } } } diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index 46eefbe8c8a4..c136bda4175d 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -1,10 +1,12 @@ import Foundation +import FoundationModels import AsyncImageKit import Support import SwiftUI import WordPressAPI import WordPressAPIInternal // Needed for `SupportUserIdentity` import WordPressCore +import WordPressCoreProtocols import WordPressData import WordPressShared import CocoaLumberjack @@ -20,7 +22,10 @@ extension SupportDataProvider { wpcomClient: WordPressDotComClient() ), supportConversationDataProvider: WpSupportConversationDataProvider( - wpcomClient: WordPressDotComClient()), + wpcomClient: WordPressDotComClient() + ), + diagnosticsDataProvider: WpDiagnosticsDataProvider(), + mediaHost: WordPressDotComClient(), delegate: WpSupportDelegate() ) } @@ -155,9 +160,12 @@ actor WpBotConversationDataProvider: BotConversationDataProvider { try await self.wpcomClient .api .supportBots - .getBotConversationList(botId: self.botId, params: ListBotConversationsParams()) + .getBotConversationList( + botId: self.botId, + params: ListBotConversationsParams(summaryMethod: .firstMessage) + ) .data - .map { $0.asSupportConversation() } + .asyncMap { try await $0.asSupportConversation() } }, cacheKey: "bot-conversation-list") } @@ -175,7 +183,7 @@ actor WpBotConversationDataProvider: BotConversationDataProvider { .getBotConversation(botId: self.botId, chatId: ChatId(id), params: params) .data - return conversation.asSupportConversation() + return try await conversation.asSupportConversation() }, cacheKey: "bot-conversation-\(id)") } @@ -210,7 +218,7 @@ actor WpBotConversationDataProvider: BotConversationDataProvider { .createBotConversation(botId: self.botId, params: params) .data - return response.asSupportConversation() + return try await response.asSupportConversation() } private func add(message: String, to conversation: Support.BotConversation) async throws -> Support.BotConversation { @@ -228,7 +236,7 @@ actor WpBotConversationDataProvider: BotConversationDataProvider { params: params ).data - return response.asSupportConversation() + return try await response.asSupportConversation() } } @@ -253,6 +261,8 @@ actor WpCurrentUserDataProvider: CurrentUserDataProvider { actor WpSupportConversationDataProvider: SupportConversationDataProvider { + let maximumUploadSize: UInt64 = 30_000_000 // 30MB + private let wpcomClient: WordPressDotComClient init(wpcomClient: WordPressDotComClient) { @@ -288,7 +298,8 @@ actor WpSupportConversationDataProvider: SupportConversationDataProvider { let params = CreateSupportTicketParams( subject: subject, message: message, - application: "jetpack" + application: "jetpack", + attachments: attachments.map { $0.path() } ) return try await self.wpcomClient.api @@ -319,6 +330,16 @@ actor WpSupportConversationDataProvider: SupportConversationDataProvider { } } +actor WpDiagnosticsDataProvider: DiagnosticsDataProvider { + func fetchDiskCacheUsage() async throws -> WordPressCoreProtocols.DiskCacheUsage { + try await DiskCache.shared.diskUsage() + } + + func clearDiskCache(progress: @escaping @Sendable (WordPressCoreProtocols.CacheDeletionProgress) async throws -> Void) async throws { + try await DiskCache.shared.removeAll(progress: progress) + } +} + extension WPComApiClient: @retroactive @unchecked Sendable {} extension WpComUserInfo { @@ -359,26 +380,41 @@ extension SupportUser { } extension WordPressAPIInternal.BotConversationSummary { - func asSupportConversation() -> Support.BotConversation { - var summary = self.summaryMessage.content + func asSupportConversation() async throws -> Support.BotConversation { - if let preview = summary.components(separatedBy: .newlines).first?.prefix(64) { - summary = String(preview) - } + let summary = try await cacheOnDisk(key: "conversation-title-\(self.chatId)", computation: { + await summarize(self.summaryMessage.content) + }) return BotConversation( id: self.chatId, title: summary, + createdAt: self.createdAt, messages: [] ) } } extension WordPressAPIInternal.BotConversation { - func asSupportConversation() -> Support.BotConversation { - BotConversation( + func asSupportConversation() async throws -> Support.BotConversation { + let title: String + + if let firstMessageText = self.messages.first?.content { + title = try await cacheOnDisk(key: "conversation-title-\(self.chatId)") { + await summarize(firstMessageText) + } + } else { + title = NSLocalizedString( + "com.jetpack.support.new-bot-chat", + value: "New Bot Chat", + comment: "The title of a new bot chat in the support area of the app" + ) + } + + return BotConversation( id: self.chatId, - title: self.messages.first?.content ?? "New Bot Chat", + title: title, + createdAt: self.createdAt, messages: self.messages.map { $0.asSupportMessage() } ) } @@ -406,12 +442,13 @@ extension WordPressAPIInternal.BotMessage { } } -extension WordPressAPIInternal.SupportConversationSummary { +extension SupportConversationSummary { func asConversationSummary() -> Support.ConversationSummary { Support.ConversationSummary( id: self.id, title: self.title, description: self.description, + status: conversationStatus(from: self.status), lastMessageSentAt: self.updatedAt ) } @@ -424,6 +461,7 @@ extension SupportConversation { title: self.title, description: self.description, lastMessageSentAt: self.updatedAt, + status: conversationStatus(from: self.status), messages: self.messages.map { $0.asMessage() } ) } @@ -438,7 +476,7 @@ extension SupportMessage { createdAt: self.createdAt, authorName: user.displayName, authorIsUser: true, - attachments: self.attachments.map { $0.asAttachment() } + attachments: self.attachments.compactMap { $0.asAttachment() } ) case .supportAgent(let agent): Message( id: self.id, @@ -446,16 +484,53 @@ extension SupportMessage { createdAt: self.createdAt, authorName: agent.name, authorIsUser: false, - attachments: self.attachments.map { $0.asAttachment() } + attachments: self.attachments.compactMap { $0.asAttachment() } ) } } } extension SupportAttachment { - func asAttachment() -> Attachment { - Attachment( - id: self.id + func asAttachment() -> Attachment? { + guard let url = URL(string: self.url) else { + return nil + } + + return Attachment( + id: self.id, + filename: self.filename, + contentType: self.contentType, + fileSize: self.size, + url: url, + dimensions: nil ) } } + +fileprivate func summarize(_ text: String) async -> String { + if #available(iOS 26.0, *) { + do { + return try await IntelligenceService().summarizeSupportTicket(content: text) + } catch { + return text + } + } else { + if let preview = text.components(separatedBy: .newlines).first?.prefix(64) { + return String(preview) + } else { + return text + } + } +} + +fileprivate func conversationStatus(from string: String) -> Support.ConversationStatus { + switch string { + case "open": .waitingForSupport + case "closed": .closed + case "pending": .waitingForUser + case "solved": .resolved + case "new": .waitingForSupport + case "hold": .waitingForSupport + default: .unknown + } +} diff --git a/WordPress/Classes/ViewRelated/Views/List/NotificationsList/AvatarView.swift b/WordPress/Classes/ViewRelated/Views/List/NotificationsList/AvatarView.swift index e4523ddf89cb..ac51092fa6ee 100644 --- a/WordPress/Classes/ViewRelated/Views/List/NotificationsList/AvatarView.swift +++ b/WordPress/Classes/ViewRelated/Views/List/NotificationsList/AvatarView.swift @@ -41,6 +41,9 @@ struct AvatarView: View { private let diameter: CGFloat @ScaledMetric private var scale = 1 + @Environment(\.displayScale) + private var screenScale: CGFloat + init( avatarShape: S = Circle(), style: Style, @@ -79,7 +82,7 @@ struct AvatarView: View { private func avatar(url: URL?) -> some View { let processedURL: URL? - let size = Int(ceil(diameter * UIScreen.main.scale)) + let size = Int(ceil(diameter * screenScale)) if let url, let gravatar = AvatarURL(url: url, options: .init(preferredSize: .pixels(size))) { processedURL = gravatar.url } else {