From 3b45000b889ba85394ba106957a44bae04f75017 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 01/12] 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 6fa743000e7a..9e8f622c9e41 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 d8b99227d9f07cff5ae60ac0ee4861cdf44bba80 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 02/12] 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 086008865068..6ea96b0e0b7c 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 b06e9355a44424fe7a8d81692688df25e5b3ac27 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 03/12] 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 6ea96b0e0b7c..7ee27c56500b 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) { _, _ in 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 32e7c91fe3a6df0d4b3fb8657c00523af73b18f7 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 04/12] 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 56608abb918e3c46ef9062351b7f34240d9ff65e 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 05/12] 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 7ee27c56500b..ab7078b1e168 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 34c0f563045a7b880401852f29d9ff8486e8dede 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 06/12] Add attachment previews for video + PDFs --- .../Helpers/FaviconService.swift | 2 +- .../AsyncImageKit/ImageDownloader.swift | 17 ++ .../AsyncImageKit/ImagePrefetcher.swift | 2 +- .../Sources/AsyncImageKit/ImageRequest.swift | 7 + .../Views/AsyncVideoThumbnailView.swift | 91 +++++++++ .../AttachmentListView.swift | 185 ++++++++++++------ 6 files changed, 245 insertions(+), 59 deletions(-) create mode 100644 Modules/Sources/AsyncImageKit/Views/AsyncVideoThumbnailView.swift diff --git a/Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift b/Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift index e8c838b3fe23..6f66717484c6 100644 --- a/Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift +++ b/Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift @@ -1,4 +1,4 @@ -import UIKit +import Foundation /// Fetches URLs for favicons for sites. public actor FaviconService { diff --git a/Modules/Sources/AsyncImageKit/ImageDownloader.swift b/Modules/Sources/AsyncImageKit/ImageDownloader.swift index 70e1f25f3bf1..1bcd86e3945a 100644 --- a/Modules/Sources/AsyncImageKit/ImageDownloader.swift +++ b/Modules/Sources/AsyncImageKit/ImageDownloader.swift @@ -1,4 +1,5 @@ import UIKit +import AVFoundation /// The system that downloads and caches images, and prepares them for display. @ImageDownloaderActor @@ -46,6 +47,20 @@ public final class ImageDownloader { } public func data(for request: ImageRequest) async throws -> Data { + + if case .video(let url) = request.source { + let asset = AVURLAsset(url: url) + let generator = AVAssetImageGenerator(asset: asset) + let result = try await generator.image(at: CMTime(seconds: 0.0, preferredTimescale: 600)) + let image = UIImage(cgImage: result.image) + + guard let data = image.pngData() else { + throw CocoaError(.fileReadUnknown) + } + + return Data(data) + } + let urlRequest = try await makeURLRequest(for: request) return try await _data(for: urlRequest, options: request.options) } @@ -63,6 +78,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") } } 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..4dff060f7817 100644 --- a/Modules/Sources/AsyncImageKit/ImageRequest.swift +++ b/Modules/Sources/AsyncImageKit/ImageRequest.swift @@ -4,11 +4,13 @@ public final class ImageRequest: Sendable { public enum Source: Sendable { case url(URL, MediaHostProtocol?) case urlRequest(URLRequest) + case video(URL) var url: URL? { switch self { case .url(let url, _): url case .urlRequest(let request): request.url + case .video(let url): url } } } @@ -25,6 +27,11 @@ public final class ImageRequest: Sendable { self.source = .urlRequest(urlRequest) self.options = options } + + public init(videoUrl: URL, options: ImageRequestOptions = ImageRequestOptions()) { + self.source = .video(videoUrl) + self.options = options + } } public struct ImageRequestOptions: Hashable, Sendable { 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 e4e87094b799faa39422e0aec40a2d71928412bc 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 07/12] 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 3c74c746853a35095dc343c5945ce3e07831802c 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 08/12] 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 cf686ac04319660923ee148a10605e68024c9941 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 09/12] 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 107b8981659f..f6ea358aac93 100644 --- a/WordPress/Classes/Networking/WordPressDotComClient.swift +++ b/WordPress/Classes/Networking/WordPressDotComClient.swift @@ -2,6 +2,7 @@ import Foundation import WordPressAPI import WordPressAPIInternal import Combine +import OSLog actor WordPressDotComClient { @@ -14,7 +15,9 @@ actor WordPressDotComClient { let delegate = WpApiClientDelegate( authProvider: .dynamic(dynamicAuthenticationProvider: provider), requestExecutor: WpRequestExecutor(urlSession: session), - middlewarePipeline: WpApiMiddlewarePipeline(middlewares: []), + middlewarePipeline: WpApiMiddlewarePipeline(middlewares: [ + WpComTrafficDebugger() + ]), appNotifier: WpComNotifier() ) @@ -94,3 +97,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 9179bec41c25bad1797cf8ce613bae3341086b22 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 10/12] Fix dependency warning --- Modules/Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/Package.swift b/Modules/Package.swift index 9e8f622c9e41..18566d99560f 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 2ce2e731425ab01159c8b60ca2278f623f4042aa 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 11/12] 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 17b51203d7ca7966c39db926039a599409cf5b5c 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 12/12] 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