Skip to content
12 changes: 10 additions & 2 deletions Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -137,7 +139,7 @@ let package = Package(
name: "Support",
dependencies: [
"AsyncImageKit",
"WordPressCore",
"WordPressCoreProtocols",
]
),
.target(name: "TextBundle"),
Expand All @@ -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(
Expand All @@ -177,6 +184,7 @@ let package = Package(
"DesignSystem",
"WordPressShared",
"WordPressLegacy",
.product(name: "ColorStudio", package: "color-studio"),
.product(name: "Reachability", package: "Reachability"),
],
resources: [.process("Resources")],
Expand Down
2 changes: 1 addition & 1 deletion Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import UIKit
import Foundation

/// Fetches URLs for favicons for sites.
public actor FaviconService {
Expand Down
17 changes: 17 additions & 0 deletions Modules/Sources/AsyncImageKit/ImageDownloader.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import UIKit
import AVFoundation

/// The system that downloads and caches images, and prepares them for display.
@ImageDownloaderActor
Expand Down Expand Up @@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's relatively expensive to convert it to data and back. Does it needs to support data(for request). Would make sense to move it under image(for request)? Alternately, data(for request) could call image(for request) and then convert it to data so both would be supported.

throw CocoaError(.fileReadUnknown)
}

return Data(data)
}

let urlRequest = try await makeURLRequest(for: request)
return try await _data(for: urlRequest, options: request.options)
}
Expand All @@ -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")
}
}

Expand Down
2 changes: 1 addition & 1 deletion Modules/Sources/AsyncImageKit/ImagePrefetcher.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import UIKit
import Foundation
import Collections

@ImageDownloaderActor
Expand Down
7 changes: 7 additions & 0 deletions Modules/Sources/AsyncImageKit/ImageRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand All @@ -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 {
Expand Down
91 changes: 91 additions & 0 deletions Modules/Sources/AsyncImageKit/Views/AsyncVideoThumbnailView.swift
Original file line number Diff line number Diff line change
@@ -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<Content>: View where Content: View {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like a copy of AsyncImageKit.CachedAsyncImage with a single line that's different:

imageDownloader.image(for: ImageRequest(videoUrl: url))

I'd suggest adding an ImageRequest init in the existing CachedAsyncImage.

@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<Image, Color> {
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<I, P>(
url: URL?,
host: MediaHostProtocol? = nil,
@ViewBuilder content: @escaping (Image) -> I,
@ViewBuilder placeholder: @escaping () -> P
) where Content == _ConditionalContent<I, P>, 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()
}
}
16 changes: 16 additions & 0 deletions Modules/Sources/Support/Extensions/Foundation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,20 @@ extension Task where Failure == Error {
return try await MainActor.run(body: operation)
}
}

enum RunForAtLeastResult<T>: Sendable where T: Sendable {
case result(T)
case wait
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This enum is not used.


static func runForAtLeast<C>(
_ 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
}
}
51 changes: 48 additions & 3 deletions Modules/Sources/Support/InternalDataProvider.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Foundation
import WordPressCore
import WordPressCoreProtocols

// This file is all module-internal and provides sample data for UI development

Expand All @@ -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())
Expand All @@ -21,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,
Expand Down Expand Up @@ -84,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,
Expand All @@ -107,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
)
]
Expand All @@ -158,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,
Expand Down Expand Up @@ -198,7 +210,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,
Expand Down Expand Up @@ -321,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]> {
Expand Down Expand Up @@ -374,6 +396,7 @@ actor InternalSupportConversationDataProvider: SupportConversationDataProvider {
title: subject,
description: message,
lastMessageSentAt: Date(),
status: .waitingForSupport,
messages: [Message(
id: 1234,
content: message,
Expand All @@ -389,3 +412,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))
}
}
}
2 changes: 1 addition & 1 deletion Modules/Sources/Support/Model/ApplicationLog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading