343 lines
12 KiB
Swift
343 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - MessageImageView
|
|
|
|
/// Displays an image attachment inside a message bubble.
|
|
///
|
|
/// Android parity: `AttachmentComponents.kt` — blurhash placeholder, download button,
|
|
/// spinner overlay, upload spinner for outgoing photos, error retry button.
|
|
///
|
|
/// States:
|
|
/// 1. **Cached + Delivered** — full image, no overlay
|
|
/// 2. **Cached + Uploading** — full image + dark overlay + upload spinner (outgoing only)
|
|
/// 3. **Downloading** — blurhash + dark overlay + spinner
|
|
/// 4. **Not Downloaded** — blurhash + dark overlay + download arrow button
|
|
/// 5. **Error** — blurhash + dark overlay + red retry button
|
|
struct MessageImageView: View {
|
|
|
|
let attachment: MessageAttachment
|
|
let message: ChatMessage
|
|
let outgoing: Bool
|
|
|
|
/// When set, the image fills this exact frame (used inside PhotoCollageView).
|
|
var collageSize: CGSize? = nil
|
|
|
|
let maxWidth: CGFloat
|
|
|
|
var onImageTap: ((UIImage) -> Void)?
|
|
|
|
@State private var image: UIImage?
|
|
@State private var blurImage: UIImage?
|
|
@State private var isDownloading = false
|
|
@State private var downloadError = false
|
|
|
|
private var isCollageCell: Bool { collageSize != nil }
|
|
|
|
// Telegram-style constraints (standalone mode)
|
|
private let maxImageWidth: CGFloat = 270
|
|
private let maxImageHeight: CGFloat = 320
|
|
private let minImageWidth: CGFloat = 140
|
|
private let minImageHeight: CGFloat = 100
|
|
private let placeholderWidth: CGFloat = 200
|
|
private let placeholderHeight: CGFloat = 200
|
|
|
|
/// Android parity: outgoing photo is "uploading" when cached but not yet delivered.
|
|
private var isUploading: Bool {
|
|
outgoing && image != nil && message.deliveryStatus == .waiting
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
if let image {
|
|
imageContent(image)
|
|
} else {
|
|
placeholderView
|
|
}
|
|
|
|
// Android parity: full-image dark overlay + centered control
|
|
if isUploading {
|
|
uploadingOverlay
|
|
} else if isDownloading {
|
|
downloadingOverlay
|
|
} else if downloadError {
|
|
errorOverlay
|
|
} else if image == nil {
|
|
downloadArrowOverlay
|
|
}
|
|
}
|
|
.task {
|
|
await loadFromCache()
|
|
if image == nil {
|
|
await decodeBlurHash()
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in
|
|
if let id = notif.object as? String, id == attachment.id, image == nil {
|
|
downloadImage()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Android-Style Overlays
|
|
|
|
/// Android: outgoing photo uploading — 28dp spinner, 0.35 alpha overlay.
|
|
private var uploadingOverlay: some View {
|
|
overlayContainer(alpha: 0.35) {
|
|
ProgressView()
|
|
.tint(.white)
|
|
.scaleEffect(0.8)
|
|
.frame(width: 28, height: 28)
|
|
}
|
|
}
|
|
|
|
/// Android: downloading — 36dp spinner, 0.3 alpha overlay.
|
|
private var downloadingOverlay: some View {
|
|
overlayContainer(alpha: 0.3) {
|
|
ProgressView()
|
|
.tint(.white)
|
|
.scaleEffect(1.0)
|
|
.frame(width: 36, height: 36)
|
|
}
|
|
}
|
|
|
|
/// Android: error — red 48dp button with refresh icon + label.
|
|
private var errorOverlay: some View {
|
|
overlayContainer(alpha: 0.3) {
|
|
VStack(spacing: 8) {
|
|
Circle()
|
|
.fill(Color(red: 0.9, green: 0.22, blue: 0.21).opacity(0.8))
|
|
.frame(width: 48, height: 48)
|
|
.overlay {
|
|
Image(systemName: "arrow.clockwise")
|
|
.font(.system(size: 20, weight: .medium))
|
|
.foregroundStyle(.white)
|
|
}
|
|
Text("Error")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Android: not downloaded — 48dp button with download arrow.
|
|
private var downloadArrowOverlay: some View {
|
|
overlayContainer(alpha: 0.3) {
|
|
Circle()
|
|
.fill(Color.black.opacity(0.5))
|
|
.frame(width: 48, height: 48)
|
|
.overlay {
|
|
Image(systemName: "arrow.down")
|
|
.font(.system(size: 20, weight: .medium))
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Android parity: full-image semi-transparent dark overlay with centered content.
|
|
private func overlayContainer<Content: View>(alpha: Double, @ViewBuilder content: () -> Content) -> some View {
|
|
ZStack {
|
|
Color.black.opacity(alpha)
|
|
content()
|
|
}
|
|
}
|
|
|
|
// MARK: - Image Content
|
|
|
|
@ViewBuilder
|
|
private func imageContent(_ img: UIImage) -> some View {
|
|
if let size = collageSize {
|
|
Image(uiImage: img)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: size.width, height: size.height)
|
|
.clipped()
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { onImageTap?(img) }
|
|
} else {
|
|
let size = constrainedSize(for: img)
|
|
Image(uiImage: img)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: size.width, height: size.height)
|
|
.clipped()
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { onImageTap?(img) }
|
|
}
|
|
}
|
|
|
|
private func constrainedSize(for img: UIImage) -> CGSize {
|
|
let constrainedWidth = min(maxImageWidth, maxWidth)
|
|
guard img.size.width > 0, img.size.height > 0 else {
|
|
return CGSize(width: min(placeholderWidth, constrainedWidth), height: placeholderHeight)
|
|
}
|
|
let aspectRatio = img.size.width / img.size.height
|
|
let displayWidth = min(constrainedWidth, max(minImageWidth, img.size.width))
|
|
let displayHeight = min(maxImageHeight, max(minImageHeight, displayWidth / max(aspectRatio, 0.01)))
|
|
let finalWidth = min(constrainedWidth, displayHeight * aspectRatio)
|
|
return CGSize(width: max(finalWidth, 1), height: max(displayHeight, 1))
|
|
}
|
|
|
|
// MARK: - Placeholder
|
|
|
|
@ViewBuilder
|
|
private var placeholderView: some View {
|
|
let size = resolvedPlaceholderSize
|
|
if let blurImage {
|
|
Image(uiImage: blurImage)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: size.width, height: size.height)
|
|
.clipped()
|
|
} else {
|
|
Rectangle()
|
|
.fill(Color.white.opacity(0.08))
|
|
.frame(width: size.width, height: size.height)
|
|
}
|
|
}
|
|
|
|
private var resolvedPlaceholderSize: CGSize {
|
|
if let size = collageSize { return size }
|
|
let w = min(placeholderWidth, min(maxImageWidth, maxWidth))
|
|
return CGSize(width: w, height: w)
|
|
}
|
|
|
|
// MARK: - BlurHash Decoding
|
|
|
|
@MainActor private static var blurHashCache: [String: UIImage] = [:]
|
|
|
|
private func decodeBlurHash() async {
|
|
let hash = extractBlurHash(from: attachment.preview)
|
|
guard !hash.isEmpty else { return }
|
|
// Fast path: cache hit (synchronous)
|
|
if let cached = Self.blurHashCache[hash] {
|
|
blurImage = cached
|
|
return
|
|
}
|
|
// Slow path: DCT decode off main thread
|
|
let result = await Task.detached(priority: .userInitiated) {
|
|
UIImage.fromBlurHash(hash, width: 32, height: 32)
|
|
}.value
|
|
guard !Task.isCancelled, let result else { return }
|
|
if Self.blurHashCache.count > 200 {
|
|
let keysToRemove = Array(Self.blurHashCache.keys.prefix(100))
|
|
for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) }
|
|
}
|
|
Self.blurHashCache[hash] = result
|
|
blurImage = result
|
|
}
|
|
|
|
// MARK: - Download
|
|
|
|
private func loadFromCache() async {
|
|
PerformanceLogger.shared.track("image.cacheLoad")
|
|
// Fast path: NSCache hit (synchronous, sub-microsecond)
|
|
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
|
image = cached
|
|
return
|
|
}
|
|
// Slow path: disk I/O + crypto — run off main thread with semaphore
|
|
// Android parity: ImageLoadSemaphore limits to 3 concurrent decode ops
|
|
let attachmentId = attachment.id
|
|
await ImageLoadLimiter.shared.acquire()
|
|
let loaded = await Task.detached(priority: .userInitiated) {
|
|
AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
|
}.value
|
|
await ImageLoadLimiter.shared.release()
|
|
if !Task.isCancelled, let loaded {
|
|
image = loaded
|
|
}
|
|
}
|
|
|
|
private func downloadImage() {
|
|
guard !isDownloading, image == nil else { return }
|
|
|
|
let tag = extractTag(from: attachment.preview)
|
|
guard !tag.isEmpty else {
|
|
downloadError = true
|
|
return
|
|
}
|
|
|
|
guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else {
|
|
downloadError = true
|
|
return
|
|
}
|
|
|
|
isDownloading = true
|
|
downloadError = false
|
|
|
|
Task {
|
|
do {
|
|
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
|
|
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
|
|
|
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
|
|
let downloadedImage = decryptAndParseImage(
|
|
encryptedString: encryptedString, passwords: passwords
|
|
)
|
|
|
|
await MainActor.run {
|
|
if let downloadedImage {
|
|
image = downloadedImage
|
|
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
|
|
} else {
|
|
downloadError = true
|
|
}
|
|
isDownloading = false
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
downloadError = true
|
|
isDownloading = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
|
|
let crypto = CryptoManager.shared
|
|
for password in passwords {
|
|
guard let data = try? crypto.decryptWithPassword(
|
|
encryptedString, password: password, requireCompression: true
|
|
) else { continue }
|
|
if let img = parseImageData(data) { return img }
|
|
}
|
|
for password in passwords {
|
|
guard let data = try? crypto.decryptWithPassword(
|
|
encryptedString, password: password
|
|
) else { continue }
|
|
if let img = parseImageData(data) { return img }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Android parity: `base64ToBitmap()` with subsampling to max 4096px.
|
|
private func parseImageData(_ data: Data) -> UIImage? {
|
|
if let str = String(data: data, encoding: .utf8) {
|
|
if str.hasPrefix("data:"),
|
|
let commaIndex = str.firstIndex(of: ",") {
|
|
let base64Part = String(str[str.index(after: commaIndex)...])
|
|
if let imageData = Data(base64Encoded: base64Part),
|
|
let img = AttachmentCache.downsampledImage(from: imageData) {
|
|
return img
|
|
}
|
|
} else if let imageData = Data(base64Encoded: str),
|
|
let img = AttachmentCache.downsampledImage(from: imageData) {
|
|
return img
|
|
}
|
|
}
|
|
return AttachmentCache.downsampledImage(from: data)
|
|
}
|
|
|
|
// MARK: - Preview Parsing
|
|
|
|
private func extractTag(from preview: String) -> String {
|
|
let parts = preview.components(separatedBy: "::")
|
|
return parts.first ?? preview
|
|
}
|
|
|
|
private func extractBlurHash(from preview: String) -> String {
|
|
let parts = preview.components(separatedBy: "::")
|
|
return parts.count > 1 ? parts[1] : ""
|
|
}
|
|
}
|