From 0f8a25c803bf92c84597fba650f270b88c5ce8be Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:45:29 -0700 Subject: [PATCH 01/23] Add video support to AsyncImageKit --- ...nimagedImage.swift => AnimatedImage.swift} | 0 .../AsyncImageKit/ImageDownloader.swift | 84 +++++++++- .../AsyncImageKit/ImagePrefetcher.swift | 2 +- .../Sources/AsyncImageKit/ImageRequest.swift | 51 +++++- .../Views/CachedAsyncImage.swift | 158 ++++++++++++++++-- .../WordPressUI/Views/SiteIconView.swift | 4 +- WordPress/Classes/Networking/MediaHost.swift | 5 + .../MediaRequestAuthenticator.swift | 19 +++ .../Networking/WordPressDotComClient.swift | 61 ++++++- .../List/NotificationsList/AvatarView.swift | 5 +- 10 files changed, 358 insertions(+), 31 deletions(-) rename Modules/Sources/AsyncImageKit/Helpers/{AnimagedImage.swift => AnimatedImage.swift} (100%) 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/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..8b5763f0d191 100644 --- a/WordPress/Classes/Networking/WordPressDotComClient.swift +++ b/WordPress/Classes/Networking/WordPressDotComClient.swift @@ -1,18 +1,22 @@ import Foundation +import AsyncImageKit +import AVFoundation import WordPressAPI import WordPressAPIInternal import Combine -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: []), appNotifier: WpComNotifier() @@ -20,6 +24,14 @@ actor WordPressDotComClient { 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 +69,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({ 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 { From cae534bfe3388da8588b840c0c11da51dc501717 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:22:28 -0700 Subject: [PATCH 02/23] Introduce+adopt WordPressCoreProtocols --- Modules/Package.swift | 11 +++- .../Support/Extensions/Foundation.swift | 16 +++++ .../Support/InternalDataProvider.swift | 27 ++++++++- .../Sources/Support/SupportDataProvider.swift | 21 ++++++- .../UI/Diagnostics/DiagnosticsView.swift | 2 +- .../UI/Diagnostics/EmptyDiskCacheView.swift | 28 ++++----- .../Sources/WordPressCore/CacheResult.swift | 35 +++++++++++ .../CachedAndFetchedResult.swift | 23 +------- Modules/Sources/WordPressCore/DiskCache.swift | 46 ++++++++------- .../CachedAndFetchedResult.swift | 21 +++++++ .../DiskCacheProtocol.swift | 59 +++++++++++++++++++ .../Extensions/Foundation+AsyncMap.swift | 14 +++++ .../Extensions/Foundation+Date.swift | 8 +++ .../NewSupport/SupportDataProvider.swift | 15 ++++- 14 files changed, 259 insertions(+), 67 deletions(-) create mode 100644 Modules/Sources/WordPressCore/CacheResult.swift create mode 100644 Modules/Sources/WordPressCoreProtocols/CachedAndFetchedResult.swift create mode 100644 Modules/Sources/WordPressCoreProtocols/DiskCacheProtocol.swift create mode 100644 Modules/Sources/WordPressCoreProtocols/Extensions/Foundation+AsyncMap.swift create mode 100644 Modules/Sources/WordPressCoreProtocols/Extensions/Foundation+Date.swift diff --git a/Modules/Package.swift b/Modules/Package.swift index 38559cc14b49..85601b6899cb 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: ["WordPressCore"]), ], 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( diff --git a/Modules/Sources/Support/Extensions/Foundation.swift b/Modules/Sources/Support/Extensions/Foundation.swift index a7e0fa8d3960..c30fd3ad62b7 100644 --- a/Modules/Sources/Support/Extensions/Foundation.swift +++ b/Modules/Sources/Support/Extensions/Foundation.swift @@ -93,4 +93,20 @@ extension Task where Failure == Error { return try await MainActor.run(body: operation) } } + + enum RunForAtLeastResult: Sendable where T: Sendable { + case result(T) + case wait + } + + 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..bfbc58a504a0 100644 --- a/Modules/Sources/Support/InternalDataProvider.swift +++ b/Modules/Sources/Support/InternalDataProvider.swift @@ -1,5 +1,5 @@ import Foundation -import WordPressCore +import WordPressCoreProtocols // This file is all module-internal and provides sample data for UI development @@ -8,7 +8,8 @@ extension SupportDataProvider { applicationLogProvider: InternalLogDataProvider(), botConversationDataProvider: InternalBotConversationDataProvider(), userDataProvider: InternalUserDataProvider(), - supportConversationDataProvider: InternalSupportConversationDataProvider() + supportConversationDataProvider: InternalSupportConversationDataProvider(), + diagnosticsDataProvider: InternalDiagnosticsDataProvider() ) static let applicationLog = ApplicationLog(path: URL(filePath: #filePath), createdAt: Date(), modifiedAt: Date()) @@ -389,3 +390,25 @@ actor InternalSupportConversationDataProvider: SupportConversationDataProvider { self.conversations[value.id] = value } } + +actor InternalDiagnosticsDataProvider: DiagnosticsDataProvider { + + func fetchDiskCacheUsage() async throws -> WordPressCoreProtocols.DiskCacheUsage { + 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)) + } + } +} diff --git a/Modules/Sources/Support/SupportDataProvider.swift b/Modules/Sources/Support/SupportDataProvider.swift index 84f003d6401e..88d48ebabffd 100644 --- a/Modules/Sources/Support/SupportDataProvider.swift +++ b/Modules/Sources/Support/SupportDataProvider.swift @@ -1,5 +1,5 @@ import Foundation -import WordPressCore +import WordPressCoreProtocols public enum SupportFormAction { case viewApplicationLogList @@ -32,6 +32,7 @@ public final class SupportDataProvider: ObservableObject, Sendable { private let botConversationDataProvider: BotConversationDataProvider private let userDataProvider: CurrentUserDataProvider private let supportConversationDataProvider: SupportConversationDataProvider + private let diagnosticsDataProvider: DiagnosticsDataProvider private weak var supportDelegate: SupportDelegate? @@ -40,12 +41,14 @@ public final class SupportDataProvider: ObservableObject, Sendable { botConversationDataProvider: BotConversationDataProvider, userDataProvider: CurrentUserDataProvider, supportConversationDataProvider: SupportConversationDataProvider, + diagnosticsDataProvider: DiagnosticsDataProvider, delegate: SupportDelegate? = nil ) { self.applicationLogProvider = applicationLogProvider self.botConversationDataProvider = botConversationDataProvider self.userDataProvider = userDataProvider self.supportConversationDataProvider = supportConversationDataProvider + self.diagnosticsDataProvider = diagnosticsDataProvider self.supportDelegate = delegate } @@ -161,6 +164,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 +225,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] diff --git a/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift b/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift index 8a6eac6cf227..1e3a2ebb867d 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 { diff --git a/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift b/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift index 5b328ce02762..1a55f59d3968 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,7 +8,7 @@ struct EmptyDiskCacheView: View { enum ViewState: Equatable { case loading - case loaded(usage: DiskCache.DiskCacheUsage) + case loaded(usage: DiskCacheUsage) case clearing(progress: Double, result: String) case error(Error) @@ -51,8 +51,6 @@ struct EmptyDiskCacheView: View { @State var state: ViewState = .loading - private let cache = DiskCache() - var body: some View { // Clear Disk Cache card DiagnosticCard( @@ -112,7 +110,7 @@ struct EmptyDiskCacheView: View { private func fetchDiskCacheUsage() async { do { - let usage = try await cache.diskUsage() + let usage = try await dataProvider.fetchDiskCacheUsage() await MainActor.run { self.state = .loaded(usage: usage) } @@ -134,18 +132,12 @@ struct EmptyDiskCacheView: View { self.state = .clearing(progress: 0, result: "") do { - try await cache.removeAll { count, total in - let progress: Double - - if count > 0 && total > 0 { - progress = Double(count) / Double(total) - } else { - progress = 0 - } - - await MainActor.run { - withAnimation { - self.state = .clearing(progress: progress, result: "Working") + try await Task.runForAtLeast(.seconds(1.5)) { + try await dataProvider.clearDiskCache { progress in + await MainActor.run { + withAnimation { + self.state = .clearing(progress: progress.progress, result: "Working") + } } } } @@ -166,5 +158,5 @@ struct EmptyDiskCacheView: View { } #Preview { - EmptyDiskCacheView() + EmptyDiskCacheView().environmentObject(SupportDataProvider.testing) } 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/WordPressCoreProtocols/Extensions/Foundation+Date.swift b/Modules/Sources/WordPressCoreProtocols/Extensions/Foundation+Date.swift new file mode 100644 index 000000000000..ff1d92b19d76 --- /dev/null +++ b/Modules/Sources/WordPressCoreProtocols/Extensions/Foundation+Date.swift @@ -0,0 +1,8 @@ +import Foundation + +public extension Date { + /// Is this date in the past? + var hasPast: Bool { + Date.now > self + } +} diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index 46eefbe8c8a4..bd4c04e3a31f 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -5,6 +5,7 @@ import SwiftUI import WordPressAPI import WordPressAPIInternal // Needed for `SupportUserIdentity` import WordPressCore +import WordPressCoreProtocols import WordPressData import WordPressShared import CocoaLumberjack @@ -20,7 +21,9 @@ extension SupportDataProvider { wpcomClient: WordPressDotComClient() ), supportConversationDataProvider: WpSupportConversationDataProvider( - wpcomClient: WordPressDotComClient()), + wpcomClient: WordPressDotComClient() + ), + diagnosticsDataProvider: WpDiagnosticsDataProvider(), delegate: WpSupportDelegate() ) } @@ -319,6 +322,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 { From 1803ff7655aab0d57e29af3b081d4ffdead3fe1a Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:23:11 -0700 Subject: [PATCH 03/23] Add ticket attachment support --- .../Support/InternalDataProvider.swift | 10 +- .../Support/Model/SupportConversation.swift | 35 +++- .../AttachmentListView.swift | 171 ++++++++++++++++++ .../SupportConversationView.swift | 29 --- .../NewSupport/SupportDataProvider.swift | 19 +- 5 files changed, 228 insertions(+), 36 deletions(-) create mode 100644 Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift diff --git a/Modules/Sources/Support/InternalDataProvider.swift b/Modules/Sources/Support/InternalDataProvider.swift index bfbc58a504a0..156ef7d0bb52 100644 --- a/Modules/Sources/Support/InternalDataProvider.swift +++ b/Modules/Sources/Support/InternalDataProvider.swift @@ -199,7 +199,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, diff --git a/Modules/Sources/Support/Model/SupportConversation.swift b/Modules/Sources/Support/Model/SupportConversation.swift index 753ae500240c..5c239f4c82fc 100644 --- a/Modules/Sources/Support/Model/SupportConversation.swift +++ b/Modules/Sources/Support/Model/SupportConversation.swift @@ -92,9 +92,42 @@ public struct Message: Identifiable, Sendable, Codable { } public struct Attachment: Identifiable, Sendable, Codable { + + public struct Dimensions: Sendable, Codable { + 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/") } } 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..e2a26ff7590f --- /dev/null +++ b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift @@ -0,0 +1,171 @@ +import SwiftUI + +struct ImageGalleryView: View { + + @Environment(\.dismiss) private var dismiss + + private let attachments: [Attachment] + private let selectedAttachment: Attachment + + init(attachments: [Attachment], selectedAttachment: Attachment) { + self.attachments = attachments.filter { $0.isImage } + self.selectedAttachment = selectedAttachment + } + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + TabView { + ForEach(attachments) { attachment in + SingleImageView(attachment: attachment) + .tag(attachment.id) + .foregroundStyle(.white) + } + } + .tabViewStyle(.page(indexDisplayMode: .always)) + .indexViewStyle(.page(backgroundDisplayMode: .always)) + }.toolbar { + ToolbarItem { + Button("Done") { + dismiss() + } + } + } + } +} + +struct SingleImageView: View { + let attachment: Attachment + + @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 { + AsyncImage(url: attachment.url) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + .scaleEffect(currentZoom) + .scaledToFit() + .gesture(magnification) + } placeholder: { + ProgressView("Loading Image") + } + } +} + +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.contentType.hasPrefix("image/") } + } + + var body: some View { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(attachments, id: \.id) { attachment in + AttachmentThumbnailView(attachment: attachment) { + if attachment.contentType.hasPrefix("image/") { + selectedAttachment = attachment + } + } + } + } + .padding(.top, 8) + .fullScreenCover(item: $selectedAttachment) { attachment in + NavigationStack { + ImageGalleryView( + attachments: imageAttachments, + selectedAttachment: attachment + ) + } + } + } +} + +struct AttachmentThumbnailView: View { + let attachment: Attachment + let onTap: () -> Void + + var body: some View { + Button { + onTap() + } label: { + ZStack { + if attachment.isImage { + AsyncImage(url: attachment.url) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Color.gray.opacity(0.2).overlay { + ProgressView() + } + } + } else { + Color.gray.opacity(0.2) + .overlay { + VStack(spacing: 4) { + Image(systemName: "doc") + .font(.title2) + .foregroundColor(.secondary) + Text(attachment.filename) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + } + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.3), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + } +} + +typealias ImageUrl = String + +extension ImageUrl: @retroactive Identifiable { + public var id: String { + self + } + + 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.url.lastPathComponent, + contentType: "image/jpeg", + fileSize: 123456, + url: $0.url + ) } + + AttachmentListView(attachments: images) +} diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift index 6634f227547a..266dd6739c5c 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift @@ -279,35 +279,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/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index bd4c04e3a31f..80b303809ece 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -451,7 +451,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, @@ -459,16 +459,25 @@ 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 ) } } From 42d84ce093ce245509154e9f6c7c0d894051be41 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:23:18 -0700 Subject: [PATCH 04/23] UI fixes --- .../Support/InternalDataProvider.swift | 2 + .../Support/Model/ApplicationLog.swift | 2 +- .../Support/Model/BotConversation.swift | 9 +- .../Support/Model/SupportConversation.swift | 10 +- .../ConversationListView.swift | 117 +++++----- .../Bot Conversations/ConversationView.swift | 213 +++++++++--------- .../UI/Diagnostics/EmptyDiskCacheView.swift | 20 +- Modules/Sources/Support/UI/ErrorView.swift | 37 +++ .../Support/UI/FullScreenProgressView.swift | 26 +++ .../Support/UI/OverlayProgressView.swift | 111 +++++---- .../ApplicationLogPicker.swift | 8 +- .../AttachmentListView.swift | 36 ++- .../ScreenshotPicker.swift | 13 +- .../SupportConversationListView.swift | 96 ++++---- .../SupportConversationReplyView.swift | 164 +++++++++----- .../SupportConversationView.swift | 88 ++++---- .../Support Conversations/SupportForm.swift | 80 +++++-- .../NewSupport/RootSupportView.swift | 20 +- 18 files changed, 617 insertions(+), 435 deletions(-) create mode 100644 Modules/Sources/Support/UI/FullScreenProgressView.swift diff --git a/Modules/Sources/Support/InternalDataProvider.swift b/Modules/Sources/Support/InternalDataProvider.swift index 156ef7d0bb52..d501b6d081b7 100644 --- a/Modules/Sources/Support/InternalDataProvider.swift +++ b/Modules/Sources/Support/InternalDataProvider.swift @@ -22,6 +22,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, @@ -85,6 +86,7 @@ extension SupportDataProvider { BotConversation( id: 5678, title: "App Crashing on Launch", + createdAt: Date().addingTimeInterval(-60), // 1 minute ago messages: botConversation.messages + [ BotMessage( id: 1009, 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 5c239f4c82fc..afb7282f55ed 100644 --- a/Modules/Sources/Support/Model/SupportConversation.swift +++ b/Modules/Sources/Support/Model/SupportConversation.swift @@ -1,6 +1,6 @@ import Foundation -public struct ConversationSummary: Identifiable, Hashable, Sendable, Codable { +public struct ConversationSummary: Identifiable, Hashable, Sendable, Codable, Equatable { public let id: UInt64 public let title: String public let description: String @@ -25,7 +25,7 @@ public struct ConversationSummary: Identifiable, Hashable, Sendable, Codable { } } -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 @@ -57,7 +57,7 @@ public struct Conversation: Identifiable, Sendable, Codable { } } -public struct Message: Identifiable, Sendable, Codable { +public struct Message: Identifiable, Sendable, Codable, Equatable { public let id: UInt64 public let content: String @@ -91,9 +91,9 @@ public struct Message: Identifiable, Sendable, Codable { } } -public struct Attachment: Identifiable, Sendable, Codable { +public struct Attachment: Identifiable, Sendable, Codable, Equatable { - public struct Dimensions: Sendable, Codable { + public struct Dimensions: Sendable, Codable, Equatable { let width: UInt64 let height: UInt64 diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift index 8affee3f884c..458d5f97bc72 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,14 +92,14 @@ 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("Loading Bot Conversations") + case .partiallyLoaded(let conversations, _), .loaded(let conversations, _): + self.conversationList(conversations) case .loadingConversationsError(let error): - ErrorView( + FullScreenErrorView( title: "Unable to load conversations", - message: error.localizedDescription + message: error ) } } @@ -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..cb172a8ce51b 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("Loading Messages") + case .partiallyLoaded(let conversation, _), .loaded(let conversation, _): + self.conversationView(messages: conversation.messages) + case .loadingMessagesError(_, let message): + FullScreenErrorView( + title: "Unable to Load Messages", + 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), @@ -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/Diagnostics/EmptyDiskCacheView.swift b/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift index 1a55f59d3968..bfa5f1acafce 100644 --- a/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift +++ b/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift @@ -111,13 +111,9 @@ struct EmptyDiskCacheView: View { private func fetchDiskCacheUsage() async { do { let usage = try await dataProvider.fetchDiskCacheUsage() - await MainActor.run { - self.state = .loaded(usage: usage) - } + self.state = .loaded(usage: usage) } catch { - await MainActor.run { - self.state = .error(error) - } + self.state = .error(error) } } @@ -142,16 +138,12 @@ struct EmptyDiskCacheView: View { } } - await MainActor.run { - withAnimation { - self.state = .clearing(progress: 1.0, result: "Complete") - } + withAnimation { + self.state = .clearing(progress: 1.0, result: "Complete") } } catch { - await MainActor.run { - withAnimation { - self.state = .error(error) - } + withAnimation { + self.state = .error(error) } } } diff --git a/Modules/Sources/Support/UI/ErrorView.swift b/Modules/Sources/Support/UI/ErrorView.swift index 47c1dc69afeb..e6380624555f 100644 --- a/Modules/Sources/Support/UI/ErrorView.swift +++ b/Modules/Sources/Support/UI/ErrorView.swift @@ -59,6 +59,43 @@ public struct ErrorView: View { } } +public struct FullScreenErrorView: View { + + let title: String + let message: String + let systemImage: String + let retryAction: (() -> Void)? + + public init( + title: String = "Something went wrong", + message: String = "Please try again later", + systemImage: String = "exclamationmark.triangle.fill", + retryAction: (() -> Void)? = nil + ) { + self.title = title + self.message = message + self.systemImage = systemImage + self.retryAction = retryAction + } + + public var body: some View { + VStack { + Spacer() + HStack { + Spacer() + ErrorView( + title: self.title, + message: self.message, + systemImage: self.systemImage, + retryAction: self.retryAction + ) + Spacer() + } + Spacer() + } + } +} + #Preview { VStack(spacing: 20) { // Basic error view 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..91e9707ad493 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("Loading latest content") + .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("Loading latest content") + .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 index e2a26ff7590f..f3922bebb3aa 100644 --- a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AsyncImageKit struct ImageGalleryView: View { @@ -18,7 +19,7 @@ struct ImageGalleryView: View { TabView { ForEach(attachments) { attachment in - SingleImageView(attachment: attachment) + SingleImageView(url: attachment.url) .tag(attachment.id) .foregroundStyle(.white) } @@ -36,7 +37,8 @@ struct ImageGalleryView: View { } struct SingleImageView: View { - let attachment: Attachment + + let url: URL @GestureState private var currentZoom = 1.0 @@ -47,7 +49,7 @@ struct SingleImageView: View { } var body: some View { - AsyncImage(url: attachment.url) { image in + CachedAsyncImage(url: url) { image in image .resizable() .aspectRatio(contentMode: .fit) @@ -57,6 +59,7 @@ struct SingleImageView: View { } placeholder: { ProgressView("Loading Image") } + .navigationTitle(url.lastPathComponent) } } @@ -70,38 +73,25 @@ struct AttachmentListView: View { ] private var imageAttachments: [Attachment] { - attachments.filter { $0.contentType.hasPrefix("image/") } + attachments.filter { $0.isImage } } var body: some View { LazyVGrid(columns: columns, spacing: 16) { - ForEach(attachments, id: \.id) { attachment in - AttachmentThumbnailView(attachment: attachment) { - if attachment.contentType.hasPrefix("image/") { - selectedAttachment = attachment - } - } + ForEach(imageAttachments, id: \.id) { attachment in + AttachmentThumbnailView(attachment: attachment) } } .padding(.top, 8) - .fullScreenCover(item: $selectedAttachment) { attachment in - NavigationStack { - ImageGalleryView( - attachments: imageAttachments, - selectedAttachment: attachment - ) - } - } } } struct AttachmentThumbnailView: View { let attachment: Attachment - let onTap: () -> Void var body: some View { - Button { - onTap() + NavigationLink { + SingleImageView(url: attachment.url) } label: { ZStack { if attachment.isImage { @@ -167,5 +157,7 @@ extension ImageUrl: @retroactive Identifiable { url: $0.url ) } - AttachmentListView(attachments: images) + NavigationStack { + AttachmentListView(attachments: images) + } } diff --git a/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift b/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift index b8647e411656..2983718f2033 100644 --- a/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift +++ b/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift @@ -95,6 +95,7 @@ struct ScreenshotPicker: View { } /// Loads selected photos from PhotosPicker + @MainActor func loadSelectedPhotos(_ items: [PhotosPickerItem]) async { var newImages: [UIImage] = [] var newUrls: [URL] = [] @@ -112,15 +113,11 @@ struct ScreenshotPicker: View { } } - await MainActor.run { - attachedImages = newImages - attachedImageUrls = newUrls - } + attachedImages = newImages + attachedImageUrls = newUrls } catch { - await MainActor.run { - withAnimation { - self.error = error - } + withAnimation { + self.error = error } } } diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift index 3d27142c7305..5fef76befe73 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 + } - await MainActor.run { + self.state = .partiallyLoaded(conversations, self.fetchTask) + } + + private var cacheTask: Task { + Task { + do { + let fetch = try dataProvider.loadSupportConversations() + + if let cachedResults = try await fetch.cachedResult() { + self.state = .partiallyLoaded(cachedResults, self.fetchTask) + } + + let fetchedResults = try await fetch.fetchedResult() self.state = .loaded(fetchedResults) - } - } catch { - 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) } } } diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift index a5e93d266a9b..779e6eec6883 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) { @@ -79,6 +105,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 +114,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("Sending Message") + .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( + "Confirm Cancellation", + isPresented: $isDisplayingCancellationConfirmation, + actions: { + Button("Discard Changes", role: .destructive) { + self.dismiss() + } + + Button("Continue Writing", role: .cancel) { + self.isDisplayingCancellationConfirmation = false + } + }, message: { + Text("Are you sure you want to cancel this message? You'll lose any data you've entered") + } + ) } @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 +208,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 +218,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 +242,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 +265,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 266dd6739c5c..57fc2f7b8619 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 { @@ -21,7 +23,7 @@ public struct SupportConversationView: View { private var dataProvider: SupportDataProvider @State - private var state: ViewState + private var state: ViewState = .start @State private var isReplying: Bool = false @@ -47,7 +49,6 @@ public struct SupportConversationView: View { conversation: ConversationSummary, currentUser: SupportUser ) { - self.state = .loading self.currentUser = currentUser self.conversationSummary = conversation } @@ -55,19 +56,18 @@ 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 { @@ -91,7 +91,7 @@ public struct SupportConversationView: View { currentUser: currentUser, conversationDidUpdate: { conversation in withAnimation { - self.state = .loaded(conversation) + self.state = .partiallyLoaded(conversation, fetchTask: self.fetchTask) } } ) @@ -102,8 +102,6 @@ public struct SupportConversationView: View { .onAppear { self.dataProvider.userDid(.viewSupportTicket(ticketId: conversationSummary.id)) } - .task(self.loadConversation) - .refreshable(action: self.reloadConversation) } @ViewBuilder @@ -144,6 +142,7 @@ public struct SupportConversationView: View { .onChange(of: conversation.messages.count) { scrollToBottom(proxy: proxy) } + .refreshable(action: self.reloadConversation) } } @@ -170,6 +169,7 @@ public struct SupportConversationView: View { .padding() } + @MainActor private func scrollToBottom(proxy: ScrollViewProxy) { guard case .loaded(let conversation) = state else { return @@ -197,43 +197,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) } } } diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift index 6a7be9f8e00c..22f9708c8ddd 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift @@ -7,6 +7,9 @@ public struct SupportForm: View { @EnvironmentObject private var dataProvider: SupportDataProvider + @Environment(\.dismiss) + private var dismiss + /// Focus state for managing field focus @FocusState private var focusedField: Field? @@ -51,6 +54,10 @@ public struct SupportForm: View { /// 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 +68,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 @@ -99,8 +113,37 @@ public struct SupportForm: View { // Submit Button Section submitButtonSection } + .scrollDismissesKeyboard(.interactively) + .interactiveDismissDisabled(self.userHasUnsavedChanges) .navigationTitle(Localization.title) .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem { + Button(Localization.cancel, role: .cancel) { + if self.userHasUnsavedChanges { + self.isDisplayingCancellationConfirmation = true + } else { + self.onDismiss?() + self.dismiss() + } + } + } + } + .alert( + "Confirm Cancellation", + isPresented: $isDisplayingCancellationConfirmation, + actions: { + Button("Discard Changes", role: .destructive) { + self.dismiss() + } + + Button("Continue Writing", role: .cancel) { + self.isDisplayingCancellationConfirmation = false + } + }, message: { + Text("Are you sure you want to cancel this message? You'll lose any data you've entered") + } + ) .alert(Localization.errorTitle, isPresented: $shouldShowErrorAlert) { Button(Localization.gotIt) { shouldShowErrorAlert = false @@ -112,6 +155,7 @@ public struct SupportForm: View { Button(Localization.gotIt) { shouldShowSuccessAlert = false onDismiss?() + self.dismiss() } } message: { Text(Localization.supportRequestSentMessage) @@ -262,6 +306,7 @@ private extension SupportForm { } /// Submits the support request + @MainActor func submitSupportRequest() { guard !submitButtonDisabled else { return } @@ -273,19 +318,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 +417,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/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) } } } From 1866c451d96e12087b7005b059bf4fcb1cced88d Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:29:15 -0700 Subject: [PATCH 05/23] Locally summarize bot conversation titles summarization Use first message for conversation title --- .../Intelligence/IntelligenceService.swift | 35 +++++++++++ .../NewSupport/SupportDataProvider.swift | 62 ++++++++++++++----- 2 files changed, 82 insertions(+), 15 deletions(-) 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/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index 80b303809ece..ef6d8ba0b65b 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -1,4 +1,5 @@ import Foundation +import FoundationModels import AsyncImageKit import Support import SwiftUI @@ -158,9 +159,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") } @@ -178,7 +182,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)") } @@ -213,7 +217,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 { @@ -231,7 +235,7 @@ actor WpBotConversationDataProvider: BotConversationDataProvider { params: params ).data - return response.asSupportConversation() + return try await response.asSupportConversation() } } @@ -291,7 +295,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 @@ -372,26 +377,37 @@ 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 = "New Bot Chat" + } + + return BotConversation( id: self.chatId, - title: self.messages.first?.content ?? "New Bot Chat", + title: title, + createdAt: self.createdAt, messages: self.messages.map { $0.asSupportMessage() } ) } @@ -419,7 +435,7 @@ extension WordPressAPIInternal.BotMessage { } } -extension WordPressAPIInternal.SupportConversationSummary { +extension SupportConversationSummary { func asConversationSummary() -> Support.ConversationSummary { Support.ConversationSummary( id: self.id, @@ -481,3 +497,19 @@ extension SupportAttachment { ) } } + +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 + } + } +} From 5bfc8f2e7e1a0b55385c85c0079e45483b5ac160 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:32:27 -0700 Subject: [PATCH 06/23] Add support conversation status support conversation status status --- .../Support/InternalDataProvider.swift | 10 ++ .../Support/Model/SupportConversation.swift | 64 ++++++++++ Modules/Sources/Support/UI/ChipView.swift | 115 ++++++++++++++++++ .../SupportConversationListView.swift | 67 +++++++--- .../SupportConversationView.swift | 92 +++++++++----- .../NewSupport/SupportDataProvider.swift | 14 +++ 6 files changed, 315 insertions(+), 47 deletions(-) create mode 100644 Modules/Sources/Support/UI/ChipView.swift diff --git a/Modules/Sources/Support/InternalDataProvider.swift b/Modules/Sources/Support/InternalDataProvider.swift index d501b6d081b7..1aa735b90083 100644 --- a/Modules/Sources/Support/InternalDataProvider.swift +++ b/Modules/Sources/Support/InternalDataProvider.swift @@ -110,48 +110,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 ) ] @@ -161,6 +169,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, @@ -385,6 +394,7 @@ actor InternalSupportConversationDataProvider: SupportConversationDataProvider { title: subject, description: message, lastMessageSentAt: Date(), + status: .waitingForSupport, messages: [Message( id: 1234, content: message, diff --git a/Modules/Sources/Support/Model/SupportConversation.swift b/Modules/Sources/Support/Model/SupportConversation.swift index afb7282f55ed..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 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, Eq id: UInt64, title: String, description: String, + status: ConversationStatus, lastMessageSentAt: Date ) { self.id = id @@ -21,6 +53,7 @@ public struct ConversationSummary: Identifiable, Hashable, Sendable, Codable, Eq self.description = description self.attributedDescription = convertMarkdownTextToAttributedString(description) self.plainTextDescription = NSAttributedString(attributedDescription).string + self.status = status self.lastMessageSentAt = lastMessageSentAt } } @@ -30,6 +63,7 @@ public struct Conversation: Identifiable, Sendable, Codable, Equatable { 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, Equatable { 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,9 +88,17 @@ public struct Conversation: Identifiable, Sendable, Codable, Equatable { title: self.title, description: self.description, lastMessageSentAt: message.createdAt, + status: self.status, messages: self.messages + [message] ) } + + /// Will the server accept a reply to this conversation? + /// + /// Unrelated to whether the user is eligible for support. + var canAcceptReply: Bool { + status != .closed + } } public struct Message: Identifiable, Sendable, Codable, Equatable { @@ -130,4 +174,24 @@ public struct Attachment: Identifiable, Sendable, Codable, Equatable { 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/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/Support Conversations/SupportConversationListView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift index 5fef76befe73..56ab45510b57 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationListView.swift @@ -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/SupportConversationView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift index 57fc2f7b8619..8b70f1fa04fb 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift @@ -17,6 +17,20 @@ 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 @@ -28,6 +42,9 @@ public struct SupportConversationView: View { @State private var isReplying: Bool = false + @Namespace + var bottom + private let conversationSummary: ConversationSummary private let currentUser: SupportUser @@ -38,11 +55,12 @@ 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( @@ -71,13 +89,15 @@ public struct SupportConversationView: View { .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 { @@ -120,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("End of conversation. No further replies are possible.") + .font(.caption) + .foregroundStyle(Color.secondary) + .padding(.top) } - .padding() - .buttonStyle(BorderedProminentButtonStyle()) - .disabled(!canReply) + + Divider() + .opacity(0) + .id(self.bottom) } } .background(Color(UIColor.systemGroupedBackground)) @@ -150,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() @@ -171,14 +201,12 @@ public struct SupportConversationView: View { @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) } } diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index ef6d8ba0b65b..14dc70088319 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -441,6 +441,7 @@ extension SupportConversationSummary { id: self.id, title: self.title, description: self.description, + status: conversationStatus(from: self.status), lastMessageSentAt: self.updatedAt ) } @@ -453,6 +454,7 @@ extension SupportConversation { title: self.title, description: self.description, lastMessageSentAt: self.updatedAt, + status: conversationStatus(from: self.status), messages: self.messages.map { $0.asMessage() } ) } @@ -513,3 +515,15 @@ fileprivate func summarize(_ text: String) async -> String { } } } + +fileprivate func conversationStatus(from string: String) -> Support.ConversationStatus { + switch string { + case "open": .waitingForSupport + case "closed": .closed + case "pending": .waitingForUser + case "solved": .resolved + case "new": .waitingForSupport + case "hold": .waitingForSupport + default: .unknown + } +} From 73568fdd01da78e3e43e65714b641dc1f6f6957b Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:32:51 -0700 Subject: [PATCH 07/23] Add attachment previews for video + PDFs --- .../Views/AsyncVideoThumbnailView.swift | 91 +++++++++ .../AttachmentListView.swift | 185 ++++++++++++------ 2 files changed, 219 insertions(+), 57 deletions(-) create mode 100644 Modules/Sources/AsyncImageKit/Views/AsyncVideoThumbnailView.swift diff --git a/Modules/Sources/AsyncImageKit/Views/AsyncVideoThumbnailView.swift b/Modules/Sources/AsyncImageKit/Views/AsyncVideoThumbnailView.swift new file mode 100644 index 000000000000..b95116de93f7 --- /dev/null +++ b/Modules/Sources/AsyncImageKit/Views/AsyncVideoThumbnailView.swift @@ -0,0 +1,91 @@ +import SwiftUI +import AVFoundation + +/// Asynchronous Image View that replicates the public API of `SwiftUI.AsyncImage` to fetch +/// a video preview thumbnail. +/// It uses `ImageDownloader` to fetch and cache the images. +public struct CachedAsyncVideoPreview: View where Content: View { + @State private var phase: AsyncImagePhase = .empty + private let url: URL? + 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() } + } + + // MARK: - Initializers + + /// 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 + if let image = phase.image { + image + } else { + Color(uiColor: .secondarySystemBackground) + } + } + } + + /// Allows content customization and providing a placeholder that will be shown + /// until the image download is finalized. + public init( + url: URL?, + host: MediaHostProtocol? = nil, + @ViewBuilder content: @escaping (Image) -> I, + @ViewBuilder placeholder: @escaping () -> P + ) where Content == _ConditionalContent, I: View, P: View { + self.init(url: url, host: host) { phase in + if let image = phase.image { + content(image) + } else { + placeholder() + } + } + } + + public init( + url: URL?, + host: MediaHostProtocol? = nil, + imageDownloader: ImageDownloader = .shared, + @ViewBuilder content: @escaping (AsyncImagePhase) -> Content + ) { + self.url = url + self.host = host + self.imageDownloader = imageDownloader + self.content = content + } + + // MARK: - Helpers + + private func fetchImage() async { + do { + guard let url else { + phase = .empty + return + } + + if let image = imageDownloader.cachedImage(for: url) { + phase = .success(Image(uiImage: image)) + } else { + let image = try await imageDownloader.image(for: ImageRequest(videoUrl: url)) + phase = .success(Image(uiImage: image)) + } + } catch { + phase = .failure(error) + } + } +} + +#Preview { + let url = URL(string: "https://a8c.zendesk.com/attachments/token/Le9xjU6B0nfYjtActesrzRrcm/?name=file_example_MP4_1920_18MG.mp4")! + + CachedAsyncVideoPreview(url: url) { image in + image.resizable().scaledToFit() + } placeholder: { + ProgressView() + } +} diff --git a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift index f3922bebb3aa..979e08edea4e 100644 --- a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift @@ -1,40 +1,7 @@ import SwiftUI import AsyncImageKit - -struct ImageGalleryView: View { - - @Environment(\.dismiss) private var dismiss - - private let attachments: [Attachment] - private let selectedAttachment: Attachment - - init(attachments: [Attachment], selectedAttachment: Attachment) { - self.attachments = attachments.filter { $0.isImage } - self.selectedAttachment = selectedAttachment - } - - var body: some View { - ZStack { - Color.black.ignoresSafeArea() - - TabView { - ForEach(attachments) { attachment in - SingleImageView(url: attachment.url) - .tag(attachment.id) - .foregroundStyle(.white) - } - } - .tabViewStyle(.page(indexDisplayMode: .always)) - .indexViewStyle(.page(backgroundDisplayMode: .always)) - }.toolbar { - ToolbarItem { - Button("Done") { - dismiss() - } - } - } - } -} +import PDFKit +import AVKit struct SingleImageView: View { @@ -63,6 +30,41 @@ struct SingleImageView: View { } } +struct SingleVideoView: View { + private let player: AVPlayer + + init(url: URL) { + self.player = AVPlayer(url: url) + } + + var body: some View { + VideoPlayer(player: player) + .ignoresSafeArea() + .onAppear { + self.player.play() + } + } +} + +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] @@ -73,16 +75,25 @@ struct AttachmentListView: View { ] private var imageAttachments: [Attachment] { - attachments.filter { $0.isImage } + attachments.filter { $0.isImage || $0.isVideo } + } + + private var otherAttachments: [Attachment] { + attachments.filter { !$0.isImage && !$0.isVideo } } var body: some View { - LazyVGrid(columns: columns, spacing: 16) { - ForEach(imageAttachments, id: \.id) { attachment in - AttachmentThumbnailView(attachment: attachment) + VStack(alignment: .leading) { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(imageAttachments) { attachment in + AttachmentThumbnailView(attachment: attachment) + } + } + + ForEach(otherAttachments) { attachment in + AttachmentRowView(attachment: attachment) } } - .padding(.top, 8) } } @@ -91,7 +102,13 @@ struct AttachmentThumbnailView: View { var body: some View { NavigationLink { - SingleImageView(url: attachment.url) + if attachment.isImage { + SingleImageView(url: attachment.url) + } + + if attachment.isVideo { + SingleVideoView(url: attachment.url) + } } label: { ZStack { if attachment.isImage { @@ -104,31 +121,61 @@ struct AttachmentThumbnailView: View { ProgressView() } } - } else { - Color.gray.opacity(0.2) - .overlay { - VStack(spacing: 4) { - Image(systemName: "doc") - .font(.title2) - .foregroundColor(.secondary) - Text(attachment.filename) - .font(.caption2) - .foregroundColor(.secondary) + } + + if attachment.isVideo { + CachedAsyncVideoPreview(url: attachment.url) { 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)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.gray.opacity(0.3), lineWidth: 0.5) - ) } .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 { @@ -136,6 +183,10 @@ extension ImageUrl: @retroactive Identifiable { self } + var filename: String { + self.url.lastPathComponent + } + var url: URL { URL(string: self)! } @@ -151,13 +202,33 @@ extension ImageUrl: @retroactive Identifiable { "https://picsum.photos/seed/5/800/600", ].map { ImageUrl($0) }.map { Attachment( id: .random(in: 0...UInt64.max), - filename: $0.url.lastPathComponent, + 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://a8c.zendesk.com/attachments/token/Le9xjU6B0nfYjtActesrzRrcm/?name=file_example_MP4_1920_18MG.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) + AttachmentListView(attachments: images + documents + videos) } } From c7356643872d968d449800339d1051c7f488ea54 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:10:25 -0700 Subject: [PATCH 08/23] Disable rich text --- .../Support/UI/Support Conversations/SupportForm.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift index 22f9708c8ddd..d540d487aa02 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift @@ -4,6 +4,8 @@ import PhotosUI public struct SupportForm: View { + private let enableRichTextForm: Bool = false + @EnvironmentObject private var dataProvider: SupportDataProvider @@ -243,7 +245,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)) @@ -298,7 +300,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) From fa306e37973aa43c7c035ee2e23ce35c9d1e445a Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:00:04 -0700 Subject: [PATCH 09/23] Add maximum upload size Add maximum upload size --- .../Support/InternalDataProvider.swift | 2 + .../Sources/Support/SupportDataProvider.swift | 6 + .../ScreenshotPicker.swift | 163 +++++++++++++----- .../SupportConversationReplyView.swift | 4 +- .../Support Conversations/SupportForm.swift | 6 +- .../NewSupport/SupportDataProvider.swift | 2 + 6 files changed, 134 insertions(+), 49 deletions(-) diff --git a/Modules/Sources/Support/InternalDataProvider.swift b/Modules/Sources/Support/InternalDataProvider.swift index 1aa735b90083..d53fbf606ac6 100644 --- a/Modules/Sources/Support/InternalDataProvider.swift +++ b/Modules/Sources/Support/InternalDataProvider.swift @@ -341,6 +341,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]> { diff --git a/Modules/Sources/Support/SupportDataProvider.swift b/Modules/Sources/Support/SupportDataProvider.swift index 88d48ebabffd..d10caebe8fa1 100644 --- a/Modules/Sources/Support/SupportDataProvider.swift +++ b/Modules/Sources/Support/SupportDataProvider.swift @@ -146,6 +146,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() @@ -258,6 +262,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/Support Conversations/ScreenshotPicker.swift b/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift index 2983718f2033..a71d7096ee40 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,54 +28,35 @@ 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 @@ -67,23 +64,31 @@ struct ScreenshotPicker: View { selection: $selectedPhotos, maxSelectionCount: maxScreenshots, matching: .images - ) { [imageCount = attachedImages.count] in + ) { [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,6 +97,58 @@ 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("Attachment Limit: \(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 @@ -99,6 +156,7 @@ struct ScreenshotPicker: View { func loadSelectedPhotos(_ items: [PhotosPickerItem]) async { var newImages: [UIImage] = [] var newUrls: [URL] = [] + var totalSize: CGFloat = 0 do { for item in items { @@ -106,6 +164,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) { @@ -113,11 +173,16 @@ struct ScreenshotPicker: View { } } - attachedImages = newImages - attachedImageUrls = newUrls + self.attachedImages = newImages + self.attachedImageUrls = newUrls + + withAnimation { + self.currentUploadSize = totalSize + self.uploadLimitExceeded = totalSize > maximumUploadSize ?? .infinity + } } catch { withAnimation { - self.error = error + self.state = .error(error) } } } @@ -159,7 +224,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/SupportConversationReplyView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift index 779e6eec6883..2454c2e21457 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift @@ -97,7 +97,9 @@ public struct SupportConversationReplyView: View { } ScreenshotPicker( - attachedImageUrls: self.$selectedPhotos + attachedImageUrls: self.$selectedPhotos, + maximumUploadSize: self.dataProvider.maximumUploadSize, + uploadLimitExceeded: self.$uploadLimitExceeded ) ApplicationLogPicker( diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift index d540d487aa02..b48e2f66e171 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift @@ -46,12 +46,14 @@ 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)? @@ -101,7 +103,9 @@ public struct SupportForm: View { // Screenshots Section ScreenshotPicker( - attachedImageUrls: $selectedPhotos + attachedImageUrls: $selectedPhotos, + maximumUploadSize: self.dataProvider.maximumUploadSize, + uploadLimitExceeded: self.$uploadLimitExceeded ) // Application Logs Section diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index 14dc70088319..1386f8b8635c 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -260,6 +260,8 @@ actor WpCurrentUserDataProvider: CurrentUserDataProvider { actor WpSupportConversationDataProvider: SupportConversationDataProvider { + let maximumUploadSize: UInt64 = 30_000_000 // 30MB + private let wpcomClient: WordPressDotComClient init(wpcomClient: WordPressDotComClient) { From 5b596595e5aed60c16ae1e2a41b6824d5e6b5fee Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:56:37 -0700 Subject: [PATCH 10/23] Add network debugging --- .../Networking/WordPressDotComClient.swift | 29 ++++++++++++++++++- WordPress/Classes/System/Logging.swift | 9 ++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 WordPress/Classes/System/Logging.swift diff --git a/WordPress/Classes/Networking/WordPressDotComClient.swift b/WordPress/Classes/Networking/WordPressDotComClient.swift index 8b5763f0d191..8edd4c4ec5a0 100644 --- a/WordPress/Classes/Networking/WordPressDotComClient.swift +++ b/WordPress/Classes/Networking/WordPressDotComClient.swift @@ -4,6 +4,7 @@ import AVFoundation import WordPressAPI import WordPressAPIInternal import Combine +import OSLog actor WordPressDotComClient: MediaHostProtocol { @@ -18,7 +19,9 @@ actor WordPressDotComClient: MediaHostProtocol { self.delegate = WpApiClientDelegate( authProvider: .dynamic(dynamicAuthenticationProvider: self.authProvider), requestExecutor: WpRequestExecutor(urlSession: session), - middlewarePipeline: WpApiMiddlewarePipeline(middlewares: []), + middlewarePipeline: WpApiMiddlewarePipeline(middlewares: [ + WpComTrafficDebugger() + ]), appNotifier: WpComNotifier() ) @@ -147,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") +} From 424e6d214efe8f99e3a319e4f9d8281f983509d0 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:57:48 -0700 Subject: [PATCH 11/23] Fix dependency warning --- Modules/Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/Package.swift b/Modules/Package.swift index 85601b6899cb..69b41e64e410 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -184,6 +184,7 @@ let package = Package( "DesignSystem", "WordPressShared", "WordPressLegacy", + .product(name: "ColorStudio", package: "color-studio"), .product(name: "Reachability", package: "Reachability"), ], resources: [.process("Resources")], From 80e53a6f50328a1671f007eeacb6e5d95b4f06ad Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 6 Nov 2025 10:20:31 -0700 Subject: [PATCH 12/23] Allow screen recordings and screenshots --- .../Support/UI/Support Conversations/ScreenshotPicker.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift b/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift index a71d7096ee40..4ea42d5fdab5 100644 --- a/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift +++ b/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift @@ -63,7 +63,10 @@ struct ScreenshotPicker: View { PhotosPicker( selection: $selectedPhotos, maxSelectionCount: maxScreenshots, - matching: .images + matching: .any(of: [ + .screenshots, + .screenRecordings + ]) ) { [imageCount = attachedImages.count, isLoading = self.state.isLoadingMoreImages, uploadLimitExceeded = self.uploadLimitExceeded] in HStack { if isLoading { From fb45bbf9b5bcb287ea18d89058b9752c2ead7e6d Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 6 Nov 2025 18:30:52 -0700 Subject: [PATCH 13/23] Fix cancel button placement --- .../Sources/Support/UI/Support Conversations/SupportForm.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift index b48e2f66e171..41df5954fa32 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift @@ -124,7 +124,7 @@ public struct SupportForm: View { .navigationTitle(Localization.title) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem { + ToolbarItem(placement: .cancellationAction) { Button(Localization.cancel, role: .cancel) { if self.userHasUnsavedChanges { self.isDisplayingCancellationConfirmation = true From a2c74e001c7722a0ee5b9aa79783716655b6a9b4 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:07:47 -0700 Subject: [PATCH 14/23] Address suggestions --- Modules/Sources/Support/Extensions/Foundation.swift | 5 ----- .../UI/Support Conversations/AttachmentListView.swift | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/Modules/Sources/Support/Extensions/Foundation.swift b/Modules/Sources/Support/Extensions/Foundation.swift index c30fd3ad62b7..57f179c2a521 100644 --- a/Modules/Sources/Support/Extensions/Foundation.swift +++ b/Modules/Sources/Support/Extensions/Foundation.swift @@ -94,11 +94,6 @@ extension Task where Failure == Error { } } - enum RunForAtLeastResult: Sendable where T: Sendable { - case result(T) - case wait - } - static func runForAtLeast( _ duration: C.Instant.Duration, operation: @escaping @Sendable () async throws -> Success, diff --git a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift index 979e08edea4e..f076d03792f4 100644 --- a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift @@ -112,7 +112,7 @@ struct AttachmentThumbnailView: View { } label: { ZStack { if attachment.isImage { - AsyncImage(url: attachment.url) { image in + CachedAsyncImage(url: attachment.url) { image in image .resizable() .aspectRatio(contentMode: .fill) From 97206f0bf73eb59ba4445f5140bad6e1e49700ff Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:51:32 -0700 Subject: [PATCH 15/23] Fix AsyncImage video support --- .../Views/AsyncVideoThumbnailView.swift | 91 ------------------- .../Support/InternalDataProvider.swift | 15 ++- .../Sources/Support/SupportDataProvider.swift | 4 + .../AttachmentListView.swift | 73 ++++++++++----- .../NewSupport/SupportDataProvider.swift | 1 + 5 files changed, 71 insertions(+), 113 deletions(-) delete mode 100644 Modules/Sources/AsyncImageKit/Views/AsyncVideoThumbnailView.swift diff --git a/Modules/Sources/AsyncImageKit/Views/AsyncVideoThumbnailView.swift b/Modules/Sources/AsyncImageKit/Views/AsyncVideoThumbnailView.swift deleted file mode 100644 index b95116de93f7..000000000000 --- a/Modules/Sources/AsyncImageKit/Views/AsyncVideoThumbnailView.swift +++ /dev/null @@ -1,91 +0,0 @@ -import SwiftUI -import AVFoundation - -/// Asynchronous Image View that replicates the public API of `SwiftUI.AsyncImage` to fetch -/// a video preview thumbnail. -/// It uses `ImageDownloader` to fetch and cache the images. -public struct CachedAsyncVideoPreview: View where Content: View { - @State private var phase: AsyncImagePhase = .empty - private let url: URL? - 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() } - } - - // MARK: - Initializers - - /// 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 - if let image = phase.image { - image - } else { - Color(uiColor: .secondarySystemBackground) - } - } - } - - /// Allows content customization and providing a placeholder that will be shown - /// until the image download is finalized. - public init( - url: URL?, - host: MediaHostProtocol? = nil, - @ViewBuilder content: @escaping (Image) -> I, - @ViewBuilder placeholder: @escaping () -> P - ) where Content == _ConditionalContent, I: View, P: View { - self.init(url: url, host: host) { phase in - if let image = phase.image { - content(image) - } else { - placeholder() - } - } - } - - public init( - url: URL?, - host: MediaHostProtocol? = nil, - imageDownloader: ImageDownloader = .shared, - @ViewBuilder content: @escaping (AsyncImagePhase) -> Content - ) { - self.url = url - self.host = host - self.imageDownloader = imageDownloader - self.content = content - } - - // MARK: - Helpers - - private func fetchImage() async { - do { - guard let url else { - phase = .empty - return - } - - if let image = imageDownloader.cachedImage(for: url) { - phase = .success(Image(uiImage: image)) - } else { - let image = try await imageDownloader.image(for: ImageRequest(videoUrl: url)) - phase = .success(Image(uiImage: image)) - } - } catch { - phase = .failure(error) - } - } -} - -#Preview { - let url = URL(string: "https://a8c.zendesk.com/attachments/token/Le9xjU6B0nfYjtActesrzRrcm/?name=file_example_MP4_1920_18MG.mp4")! - - CachedAsyncVideoPreview(url: url) { image in - image.resizable().scaledToFit() - } placeholder: { - ProgressView() - } -} diff --git a/Modules/Sources/Support/InternalDataProvider.swift b/Modules/Sources/Support/InternalDataProvider.swift index d53fbf606ac6..ee4814682c32 100644 --- a/Modules/Sources/Support/InternalDataProvider.swift +++ b/Modules/Sources/Support/InternalDataProvider.swift @@ -1,4 +1,6 @@ import Foundation +import AVFoundation +import AsyncImageKit import WordPressCoreProtocols // This file is all module-internal and provides sample data for UI development @@ -9,7 +11,8 @@ extension SupportDataProvider { botConversationDataProvider: InternalBotConversationDataProvider(), userDataProvider: InternalUserDataProvider(), supportConversationDataProvider: InternalSupportConversationDataProvider(), - diagnosticsDataProvider: InternalDiagnosticsDataProvider() + diagnosticsDataProvider: InternalDiagnosticsDataProvider(), + mediaHost: InternalMediaHost() ) static let applicationLog = ApplicationLog(path: URL(filePath: #filePath), createdAt: Date(), modifiedAt: Date()) @@ -434,3 +437,13 @@ actor InternalDiagnosticsDataProvider: DiagnosticsDataProvider { } } } + +actor InternalMediaHost: MediaHostProtocol { + func authenticatedRequest(for url: URL) async throws -> URLRequest { + URLRequest(url: url) + } + + func authenticatedAsset(for url: URL) async throws -> AVURLAsset { + AVURLAsset(url: url) + } +} diff --git a/Modules/Sources/Support/SupportDataProvider.swift b/Modules/Sources/Support/SupportDataProvider.swift index d10caebe8fa1..3991debdfa22 100644 --- a/Modules/Sources/Support/SupportDataProvider.swift +++ b/Modules/Sources/Support/SupportDataProvider.swift @@ -1,4 +1,5 @@ import Foundation +import AsyncImageKit import WordPressCoreProtocols public enum SupportFormAction { @@ -33,6 +34,7 @@ public final class SupportDataProvider: ObservableObject, Sendable { private let userDataProvider: CurrentUserDataProvider private let supportConversationDataProvider: SupportConversationDataProvider private let diagnosticsDataProvider: DiagnosticsDataProvider + let mediaHost: MediaHostProtocol private weak var supportDelegate: SupportDelegate? @@ -42,6 +44,7 @@ public final class SupportDataProvider: ObservableObject, Sendable { userDataProvider: CurrentUserDataProvider, supportConversationDataProvider: SupportConversationDataProvider, diagnosticsDataProvider: DiagnosticsDataProvider, + mediaHost: MediaHostProtocol, delegate: SupportDelegate? = nil ) { self.applicationLogProvider = applicationLogProvider @@ -49,6 +52,7 @@ public final class SupportDataProvider: ObservableObject, Sendable { self.userDataProvider = userDataProvider self.supportConversationDataProvider = supportConversationDataProvider self.diagnosticsDataProvider = diagnosticsDataProvider + self.mediaHost = mediaHost self.supportDelegate = delegate } diff --git a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift index f076d03792f4..ffbd0919fd6c 100644 --- a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift @@ -31,18 +31,41 @@ struct SingleImageView: View { } struct SingleVideoView: View { - private let player: AVPlayer - init(url: URL) { - self.player = AVPlayer(url: url) + @State + private var player: AVPlayer? = 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 { - VideoPlayer(player: player) - .ignoresSafeArea() - .onAppear { - self.player.play() + Group { + if let player { + VideoPlayer(player: player) + .ignoresSafeArea() + .onAppear { + player.play() + } + } else { + FullScreenProgressView("Loading Video") + } + }.task { + if let host { + do { + let asset = try await host.authenticatedAsset(for: url) + self.player = AVPlayer(playerItem: AVPlayerItem(asset: asset)) + } catch { + debugPrint(error.localizedDescription) + } + } else { + self.player = AVPlayer(url: url) } + } } } @@ -98,6 +121,10 @@ struct AttachmentListView: View { } struct AttachmentThumbnailView: View { + + @EnvironmentObject + private var supportDataProvider: SupportDataProvider + let attachment: Attachment var body: some View { @@ -107,12 +134,12 @@ struct AttachmentThumbnailView: View { } if attachment.isVideo { - SingleVideoView(url: attachment.url) + SingleVideoView(url: attachment.url, host: supportDataProvider.mediaHost) } } label: { ZStack { if attachment.isImage { - CachedAsyncImage(url: attachment.url) { image in + CachedAsyncImage(url: attachment.url, host: supportDataProvider.mediaHost, mutability: .immutable) { image in image .resizable() .aspectRatio(contentMode: .fill) @@ -120,24 +147,28 @@ struct AttachmentThumbnailView: View { Color.gray.opacity(0.2).overlay { ProgressView() } + } } if attachment.isVideo { - CachedAsyncVideoPreview(url: attachment.url) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - .overlay { - Image(systemName: "play.circle") - .foregroundStyle(Color.white) + 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() } - - } placeholder: { - Color.gray.opacity(0.2).overlay { - ProgressView() } - } } } .frame(width: 80, height: 80) diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index 1386f8b8635c..6d9f9a0b6bc3 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -25,6 +25,7 @@ extension SupportDataProvider { wpcomClient: WordPressDotComClient() ), diagnosticsDataProvider: WpDiagnosticsDataProvider(), + mediaHost: WordPressDotComClient(), delegate: WpSupportDelegate() ) } From 04d4c15686611e18022440f0e346ba77b9335463 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 18 Nov 2025 21:32:19 -0700 Subject: [PATCH 16/23] Use ContentUnavailableView --- Modules/Sources/Support/UI/ErrorView.swift | 32 ++++++++++------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/Modules/Sources/Support/UI/ErrorView.swift b/Modules/Sources/Support/UI/ErrorView.swift index e6380624555f..39ae99bb3371 100644 --- a/Modules/Sources/Support/UI/ErrorView.swift +++ b/Modules/Sources/Support/UI/ErrorView.swift @@ -64,35 +64,23 @@ public struct FullScreenErrorView: View { let title: String let message: String let systemImage: String - let retryAction: (() -> Void)? public init( title: String = "Something went wrong", message: String = "Please try again later", - systemImage: String = "exclamationmark.triangle.fill", - retryAction: (() -> Void)? = nil + systemImage: String = "exclamationmark.triangle.fill" ) { self.title = title self.message = message self.systemImage = systemImage - self.retryAction = retryAction } public var body: some View { - VStack { - Spacer() - HStack { - Spacer() - ErrorView( - title: self.title, - message: self.message, - systemImage: self.systemImage, - retryAction: self.retryAction - ) - Spacer() - } - Spacer() - } + ContentUnavailableView( + self.title, + systemImage: self.systemImage, + description: Text(self.message) + ) } } @@ -120,3 +108,11 @@ public struct FullScreenErrorView: 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" + ) +} From 13a173de7c234726572a2c9991ce079f59095c87 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 18 Nov 2025 21:41:46 -0700 Subject: [PATCH 17/23] Add localization --- Modules/Sources/Support/Localization.swift | 253 ++++++++++++++++++ .../ActivityLogListView.swift | 26 +- .../ActivityLogSharingView.swift | 29 +- .../ConversationBotIntro.swift | 4 +- .../ConversationListView.swift | 6 +- .../Bot Conversations/ConversationView.swift | 2 +- .../UI/Bot Conversations/ThinkingView.swift | 2 +- .../UI/Diagnostics/DiagnosticsView.swift | 4 +- .../UI/Diagnostics/EmptyDiskCacheView.swift | 16 +- Modules/Sources/Support/UI/ErrorView.swift | 2 +- .../ScreenshotPicker.swift | 2 +- .../SupportConversationReplyView.swift | 8 +- .../SupportConversationView.swift | 2 +- .../Support Conversations/SupportForm.swift | 10 +- 14 files changed, 313 insertions(+), 53 deletions(-) diff --git a/Modules/Sources/Support/Localization.swift b/Modules/Sources/Support/Localization.swift index 48c63e21f72d..95b492e973cd 100644 --- a/Modules/Sources/Support/Localization.swift +++ b/Modules/Sources/Support/Localization.swift @@ -216,4 +216,257 @@ 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" + ) + 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" + ) + + // MARK: - ConversationView.swift + + static let openSupportTicket = NSLocalizedString( + "com.jetpack.support.openSupportTicket", + value: "Open a Support Ticket", + comment: "Button to open a support ticket" + ) + + // 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" + ) } 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/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 458d5f97bc72..a2dfcf273229 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift @@ -103,7 +103,7 @@ public struct ConversationListView: View { ) } } - .navigationTitle("Conversations") + .navigationTitle(Localization.conversations) .toolbar { ToolbarItem(placement: .primaryAction) { NavigationLink { @@ -132,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 diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift index cb172a8ce51b..f3b796490557 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift @@ -400,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) 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/Diagnostics/DiagnosticsView.swift b/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift index 1e3a2ebb867d..956675eb4e55 100644 --- a/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift +++ b/Modules/Sources/Support/UI/Diagnostics/DiagnosticsView.swift @@ -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 bfa5f1acafce..731b869f2431 100644 --- a/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift +++ b/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift @@ -54,15 +54,15 @@ struct EmptyDiskCacheView: View { 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() } } label: { - Label(self.state.isClearingCache ? "Clearing…" : "Clear Disk Cache", systemImage: self.state.isClearingCache ? "hourglass" : "trash") + Label(self.state.isClearingCache ? Localization.clearing : Localization.clearDiskCache, systemImage: self.state.isClearingCache ? "hourglass" : "trash") } .buttonStyle(.borderedProminent) .disabled(self.state.buttonIsDisabled) @@ -71,14 +71,14 @@ struct EmptyDiskCacheView: View { VStack(alignment: .leading, spacing: 6) { switch self.state { case .loading: - ProgressView("Loading Disk Usage") + ProgressView(Localization.loadingDiskUsage) case .loaded(let usage): if usage.isEmpty { - Text("Cache is empty") + Text(Localization.cacheIsEmpty) .font(.footnote) .foregroundStyle(.secondary) } else { - Text("^[\(usage.fileCount) cache files](inflect: true) (\(usage.formattedDiskUsage))") + Text(String.localizedStringWithFormat(Localization.cacheFiles, usage.fileCount, usage.formattedDiskUsage)) .font(.footnote) .foregroundStyle(.secondary) } @@ -132,14 +132,14 @@ struct EmptyDiskCacheView: View { try await dataProvider.clearDiskCache { progress in await MainActor.run { withAnimation { - self.state = .clearing(progress: progress.progress, result: "Working") + self.state = .clearing(progress: progress.progress, result: Localization.working) } } } } withAnimation { - self.state = .clearing(progress: 1.0, result: "Complete") + self.state = .clearing(progress: 1.0, result: Localization.complete) } } catch { withAnimation { diff --git a/Modules/Sources/Support/UI/ErrorView.swift b/Modules/Sources/Support/UI/ErrorView.swift index 39ae99bb3371..38aaa9ce7aa2 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) diff --git a/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift b/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift index 4ea42d5fdab5..ccb3857e721e 100644 --- a/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift +++ b/Modules/Sources/Support/UI/Support Conversations/ScreenshotPicker.swift @@ -143,7 +143,7 @@ struct ScreenshotPicker: View { ProgressView(value: currentUploadSize, total: maximumUploadSize) .tint(uploadLimitExceeded ? Color.red : Color.accentColor) - Text("Attachment Limit: \(format(bytes: currentUploadSize)) / \(format(bytes: maximumUploadSize))") + Text(String.localizedStringWithFormat(Localization.attachmentLimit, format(bytes: currentUploadSize), format(bytes: maximumUploadSize))) .font(.caption2) .foregroundStyle(Color.secondary) } diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift index 2454c2e21457..0e7914365215 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift @@ -176,18 +176,18 @@ public struct SupportConversationReplyView: View { isTextFieldFocused = true } .alert( - "Confirm Cancellation", + Localization.confirmCancellation, isPresented: $isDisplayingCancellationConfirmation, actions: { - Button("Discard Changes", role: .destructive) { + Button(Localization.discardChanges, role: .destructive) { self.dismiss() } - Button("Continue Writing", role: .cancel) { + Button(Localization.continueWriting, role: .cancel) { self.isDisplayingCancellationConfirmation = false } }, message: { - Text("Are you sure you want to cancel this message? You'll lose any data you've entered") + Text(Localization.confirmCancelMessage) } ) } diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift index 8b70f1fa04fb..cda07720ef86 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationView.swift @@ -156,7 +156,7 @@ public struct SupportConversationView: View { .buttonStyle(BorderedProminentButtonStyle()) .disabled(!canReply) } else { - Text("End of conversation. No further replies are possible.") + Text(Localization.conversationEnded) .font(.caption) .foregroundStyle(Color.secondary) .padding(.top) diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift index 41df5954fa32..2befa8f95ef9 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportForm.swift @@ -136,18 +136,18 @@ public struct SupportForm: View { } } .alert( - "Confirm Cancellation", + Localization.confirmCancellation, isPresented: $isDisplayingCancellationConfirmation, actions: { - Button("Discard Changes", role: .destructive) { + Button(Localization.discardChanges, role: .destructive) { self.dismiss() } - Button("Continue Writing", role: .cancel) { + Button(Localization.continueWriting, role: .cancel) { self.isDisplayingCancellationConfirmation = false } }, message: { - Text("Are you sure you want to cancel this message? You'll lose any data you've entered") + Text(Localization.confirmCancelMessage) } ) .alert(Localization.errorTitle, isPresented: $shouldShowErrorAlert) { @@ -200,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) From a004b3680dfbe1287ab809f73e98359724f5fa9f Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 18 Nov 2025 23:00:43 -0700 Subject: [PATCH 18/23] Fix up EmptyDiskCacheView --- .../Support/Extensions/Foundation.swift | 18 ++ .../Support/InternalDataProvider.swift | 10 +- .../UI/Diagnostics/EmptyDiskCacheView.swift | 161 ++++++++++++------ 3 files changed, 135 insertions(+), 54 deletions(-) diff --git a/Modules/Sources/Support/Extensions/Foundation.swift b/Modules/Sources/Support/Extensions/Foundation.swift index 57f179c2a521..adf3f0164fe4 100644 --- a/Modules/Sources/Support/Extensions/Foundation.swift +++ b/Modules/Sources/Support/Extensions/Foundation.swift @@ -7,6 +7,24 @@ extension Date { } } +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 { func toHtml() -> String { NSAttributedString(self).toHtml() diff --git a/Modules/Sources/Support/InternalDataProvider.swift b/Modules/Sources/Support/InternalDataProvider.swift index ee4814682c32..8c14fce22142 100644 --- a/Modules/Sources/Support/InternalDataProvider.swift +++ b/Modules/Sources/Support/InternalDataProvider.swift @@ -418,8 +418,14 @@ actor InternalSupportConversationDataProvider: SupportConversationDataProvider { actor InternalDiagnosticsDataProvider: DiagnosticsDataProvider { + private var didClear: Bool = false + func fetchDiskCacheUsage() async throws -> WordPressCoreProtocols.DiskCacheUsage { - DiskCacheUsage(fileCount: 64, byteCount: 623_423_562) + 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 { @@ -435,6 +441,8 @@ actor InternalDiagnosticsDataProvider: DiagnosticsDataProvider { // Report incremental progress try await progress(CacheDeletionProgress(filesDeleted: i, totalFileCount: totalFiles)) } + + self.didClear = true } } diff --git a/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift b/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift index 731b869f2431..a91b895365cb 100644 --- a/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift +++ b/Modules/Sources/Support/UI/Diagnostics/EmptyDiskCacheView.swift @@ -9,8 +9,8 @@ struct EmptyDiskCacheView: View { enum ViewState: Equatable { case loading case loaded(usage: DiskCacheUsage) - case clearing(progress: Double, result: String) - case error(Error) + case clearing(progress: Double, result: String, task: Task) + case error(String) var isClearingCache: Bool { if case .clearing = self { @@ -32,19 +32,65 @@ 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 } } @@ -60,48 +106,36 @@ struct EmptyDiskCacheView: View { ) { VStack(alignment: .leading, spacing: 12) { Button { - Task { await clearDiskCache() } + clearDiskCache() } label: { - Label(self.state.isClearingCache ? Localization.clearing : Localization.clearDiskCache, 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: + if case .loading = state { ProgressView(Localization.loadingDiskUsage) - case .loaded(let usage): - if usage.isEmpty { - Text(Localization.cacheIsEmpty) - .font(.footnote) - .foregroundStyle(.secondary) - } else { - Text(String.localizedStringWithFormat(Localization.cacheFiles, usage.fileCount, usage.formattedDiskUsage)) - .font(.footnote) - .foregroundStyle(.secondary) - } - case .clearing(let progress, let status): - ProgressView(value: progress) + } 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) @@ -113,37 +147,58 @@ struct EmptyDiskCacheView: View { let usage = try await dataProvider.fetchDiskCacheUsage() self.state = .loaded(usage: usage) } catch { - 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: "") + 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 + ) + } + } - do { - try await Task.runForAtLeast(.seconds(1.5)) { - try await dataProvider.clearDiskCache { progress in await MainActor.run { withAnimation { - self.state = .clearing(progress: progress.progress, result: Localization.working) + self.state = .clearing( + progress: 1.0, + result: Localization.complete, + task: task + ) } } } - } - withAnimation { - self.state = .clearing(progress: 1.0, result: Localization.complete) - } - } catch { - withAnimation { - self.state = .error(error) + let usage = try await dataProvider.fetchDiskCacheUsage() + + withAnimation { + self.state = .loaded(usage: usage) + } + } catch { + withAnimation { + self.state = .error(error.localizedDescription) + } } } } From 117f0c9f24a00b2ddca30bd353719a592a5bf707 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 18 Nov 2025 23:17:35 -0700 Subject: [PATCH 19/23] Fix a library issue --- Modules/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Package.swift b/Modules/Package.swift index 69b41e64e410..aa5202185014 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -22,7 +22,7 @@ let package = Package( .library(name: "WordPressUI", targets: ["WordPressUI"]), .library(name: "WordPressReader", targets: ["WordPressReader"]), .library(name: "WordPressCore", targets: ["WordPressCore"]), - .library(name: "WordPressCoreProtocols", targets: ["WordPressCore"]), + .library(name: "WordPressCoreProtocols", targets: ["WordPressCoreProtocols"]), ], dependencies: [ .package(url: "https://github.com/airbnb/lottie-ios", from: "4.4.0"), From 92e75e709df9601a80545b3a4d433adbcb253ca2 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 18 Nov 2025 23:17:43 -0700 Subject: [PATCH 20/23] Fix some missed localizations --- Modules/Sources/Support/Localization.swift | 59 +++++++++++++++++++ .../ActivityLogDetailView.swift | 2 +- .../ConversationListView.swift | 4 +- .../Bot Conversations/ConversationView.swift | 4 +- .../Support/UI/OverlayProgressView.swift | 4 +- .../AttachmentListView.swift | 4 +- .../SupportConversationReplyView.swift | 2 +- 7 files changed, 69 insertions(+), 10 deletions(-) diff --git a/Modules/Sources/Support/Localization.swift b/Modules/Sources/Support/Localization.swift index 95b492e973cd..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", @@ -305,6 +310,14 @@ enum Localization { value: "Application Logs", comment: "Navigation title for application logs screen" ) + + // MARK: - ActivityLogDetailView.swift + + static let loadingLogContent = NSLocalizedString( + "com.jetpack.support.loadingLogContent", + value: "Loading log content...", + comment: "Progress message while loading application log content" + ) static let confirmDeleteAllLogs = NSLocalizedString( "com.jetpack.support.confirmDeleteAllLogs", value: "Are you sure you want to delete all logs?", @@ -416,6 +429,16 @@ enum Localization { 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 @@ -424,6 +447,16 @@ enum Localization { 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 @@ -469,4 +502,30 @@ enum Localization { 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/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/Bot Conversations/ConversationListView.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift index a2dfcf273229..58c70bb72e76 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationListView.swift @@ -93,12 +93,12 @@ public struct ConversationListView: View { VStack { switch self.state { case .start, .loading: - FullScreenProgressView("Loading Bot Conversations") + FullScreenProgressView(Localization.loadingBotConversations) case .partiallyLoaded(let conversations, _), .loaded(let conversations, _): self.conversationList(conversations) case .loadingConversationsError(let error): FullScreenErrorView( - title: "Unable to load conversations", + title: Localization.unableToLoadConversations, message: error ) } diff --git a/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift b/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift index f3b796490557..8caf3365d2cf 100644 --- a/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift +++ b/Modules/Sources/Support/UI/Bot Conversations/ConversationView.swift @@ -228,12 +228,12 @@ public struct ConversationView: View { VStack { switch self.state { case .start, .loadingMessages: - FullScreenProgressView("Loading Messages") + FullScreenProgressView(Localization.loadingBotConversationMessages) case .partiallyLoaded(let conversation, _), .loaded(let conversation, _): self.conversationView(messages: conversation.messages) case .loadingMessagesError(_, let message): FullScreenErrorView( - title: "Unable to Load Messages", + title: Localization.unableToLoadMessages, message: message ) case .startingNewConversation: diff --git a/Modules/Sources/Support/UI/OverlayProgressView.swift b/Modules/Sources/Support/UI/OverlayProgressView.swift index 91e9707ad493..ecdb0db124b1 100644 --- a/Modules/Sources/Support/UI/OverlayProgressView.swift +++ b/Modules/Sources/Support/UI/OverlayProgressView.swift @@ -40,7 +40,7 @@ struct OverlayProgressView: View { ProgressView() .progressViewStyle(.circular) - Text("Loading latest content") + Text(Localization.loadingLatestContent) .font(.callout) .foregroundStyle(.primary) } @@ -55,7 +55,7 @@ struct OverlayProgressView: View { .opacity(state.isVisible ? 1 : 0) .offset(y: state.isVisible ? 0 : -12) .accessibilityElement(children: .combine) - .accessibilityLabel("Loading latest content") + .accessibilityLabel(Localization.loadingLatestContent) .accessibilityAddTraits(.isStaticText) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .padding(.top, 24) diff --git a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift index ffbd0919fd6c..f56d23d6108b 100644 --- a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift @@ -24,7 +24,7 @@ struct SingleImageView: View { .scaledToFit() .gesture(magnification) } placeholder: { - ProgressView("Loading Image") + ProgressView(Localization.loadingImage) } .navigationTitle(url.lastPathComponent) } @@ -52,7 +52,7 @@ struct SingleVideoView: View { player.play() } } else { - FullScreenProgressView("Loading Video") + FullScreenProgressView(Localization.loadingVideo) } }.task { if let host { diff --git a/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift b/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift index 0e7914365215..252324bac31c 100644 --- a/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/SupportConversationReplyView.swift @@ -130,7 +130,7 @@ public struct SupportConversationReplyView: View { } .overlay { ZStack { - ProgressView("Sending Message") + ProgressView(Localization.sendingMessage) .padding() .background(Color(UIColor.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) From ded9e2e03718e06c5c6d109a9277a61689b5aef9 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 18 Nov 2025 23:25:48 -0700 Subject: [PATCH 21/23] Handle video loading errors --- .../Sources/Support/InternalDataProvider.swift | 12 ++++++++++-- .../UI/Bot Conversations/CompositionView.swift | 3 +-- Modules/Sources/Support/UI/ErrorView.swift | 2 +- .../AttachmentListView.swift | 15 ++++++++++++--- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/Support/InternalDataProvider.swift b/Modules/Sources/Support/InternalDataProvider.swift index 8c14fce22142..07f3747c6e8d 100644 --- a/Modules/Sources/Support/InternalDataProvider.swift +++ b/Modules/Sources/Support/InternalDataProvider.swift @@ -448,10 +448,18 @@ actor InternalDiagnosticsDataProvider: DiagnosticsDataProvider { actor InternalMediaHost: MediaHostProtocol { func authenticatedRequest(for url: URL) async throws -> URLRequest { - URLRequest(url: url) + if Bool.random() { + throw CocoaError(.coderInvalidValue) + } + + return URLRequest(url: url) } func authenticatedAsset(for url: URL) async throws -> AVURLAsset { - AVURLAsset(url: url) + if Bool.random() { + throw CocoaError(.coderInvalidValue) + } + + return AVURLAsset(url: url) } } 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/ErrorView.swift b/Modules/Sources/Support/UI/ErrorView.swift index 38aaa9ce7aa2..e73099c3ab67 100644 --- a/Modules/Sources/Support/UI/ErrorView.swift +++ b/Modules/Sources/Support/UI/ErrorView.swift @@ -95,7 +95,7 @@ public struct FullScreenErrorView: 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 } ) diff --git a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift index f56d23d6108b..5663124c4c4c 100644 --- a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift @@ -35,6 +35,9 @@ struct SingleVideoView: View { @State private var player: AVPlayer? = nil + @State + private var error: Error? = nil + private let url: URL private let host: MediaHostProtocol? @@ -51,6 +54,12 @@ struct SingleVideoView: View { .onAppear { player.play() } + } else if let error { + FullScreenErrorView( + title: Localization.unableToDisplayVideo, + message: error.localizedDescription, + systemImage: "film" + ) } else { FullScreenProgressView(Localization.loadingVideo) } @@ -60,7 +69,7 @@ struct SingleVideoView: View { let asset = try await host.authenticatedAsset(for: url) self.player = AVPlayer(playerItem: AVPlayerItem(asset: asset)) } catch { - debugPrint(error.localizedDescription) + self.error = error } } else { self.player = AVPlayer(url: url) @@ -250,7 +259,7 @@ extension ImageUrl: @retroactive Identifiable { )} let videos = [ - "https://a8c.zendesk.com/attachments/token/Le9xjU6B0nfYjtActesrzRrcm/?name=file_example_MP4_1920_18MG.mp4" + "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", @@ -261,5 +270,5 @@ extension ImageUrl: @retroactive Identifiable { NavigationStack { AttachmentListView(attachments: images + documents + videos) - } + }.environmentObject(SupportDataProvider.testing) } From b203874dbe706c6b53c471b958736c202fb24f91 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 19 Nov 2025 17:20:39 -0700 Subject: [PATCH 22/23] Fix a missed localized string --- .../ViewRelated/NewSupport/SupportDataProvider.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index 6d9f9a0b6bc3..c136bda4175d 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -404,7 +404,11 @@ extension WordPressAPIInternal.BotConversation { await summarize(firstMessageText) } } else { - title = "New Bot Chat" + 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( From 6c07ae186a667b9cbc36f11e21e8aea87a59b6f3 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 19 Nov 2025 17:23:33 -0700 Subject: [PATCH 23/23] Move support-specific extension into support --- Modules/Sources/Support/Extensions/Foundation.swift | 4 ++++ .../Extensions/Foundation+Date.swift | 8 -------- 2 files changed, 4 insertions(+), 8 deletions(-) delete mode 100644 Modules/Sources/WordPressCoreProtocols/Extensions/Foundation+Date.swift diff --git a/Modules/Sources/Support/Extensions/Foundation.swift b/Modules/Sources/Support/Extensions/Foundation.swift index adf3f0164fe4..59daf9c6b2b5 100644 --- a/Modules/Sources/Support/Extensions/Foundation.swift +++ b/Modules/Sources/Support/Extensions/Foundation.swift @@ -5,6 +5,10 @@ extension Date { let calendar = Calendar.autoupdatingCurrent return calendar.isDateInToday(self) } + + var hasPast: Bool { + Date.now > self + } } extension String { diff --git a/Modules/Sources/WordPressCoreProtocols/Extensions/Foundation+Date.swift b/Modules/Sources/WordPressCoreProtocols/Extensions/Foundation+Date.swift deleted file mode 100644 index ff1d92b19d76..000000000000 --- a/Modules/Sources/WordPressCoreProtocols/Extensions/Foundation+Date.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -public extension Date { - /// Is this date in the past? - var hasPast: Bool { - Date.now > self - } -}