Files
mobile-ios/Rosetta/Features/Chats/ChatDetail/MessageImageView.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] : ""
}
}