Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/LinkPreview/LinkPreviewState.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

public import SignalServiceKit

public enum LinkPreviewImageState: Equatable {
    case none
    case loading(blurHash: String?)
    case loaded
    case invalid
    case failed(blurHash: String?)
}

// MARK: -

public struct LinkPreviewImageCacheKey: Hashable, Equatable {
    public let id: Attachment.IDType?
    public let isBlurHash: Bool
    public let urlString: String?
    public let thumbnailQuality: AttachmentThumbnailQuality

    public init(
        id: Attachment.IDType?,
        urlString: String?,
        isBlurHash: Bool = false,
        thumbnailQuality: AttachmentThumbnailQuality,
    ) {
        self.id = id
        self.urlString = urlString
        self.isBlurHash = isBlurHash
        self.thumbnailQuality = thumbnailQuality
    }
}

public protocol LinkPreviewState: AnyObject {
    var isLoaded: Bool { get }
    var urlString: String? { get }
    var displayDomain: String? { get }
    var title: String? { get }
    var imageState: LinkPreviewImageState { get }
    func imageAsync(
        thumbnailQuality: AttachmentThumbnailQuality,
        completion: @escaping (UIImage) -> Void,
    )
    func imageCacheKey(thumbnailQuality: AttachmentThumbnailQuality) -> LinkPreviewImageCacheKey?
    var imagePixelSize: CGSize { get }
    var previewDescription: String? { get }
    var date: Date? { get }
    var isGroupInviteLink: Bool { get }
    var isCallLink: Bool { get }
    var conversationStyle: ConversationStyle? { get }
}

// MARK: -

public extension LinkPreviewState {
    var hasLoadedImageOrBlurHash: Bool {
        switch imageState {
        case .loaded:
            return isLoaded
        case let .loading(blurHash), let .failed(blurHash):
            return blurHash != nil
        default:
            return false
        }
    }

    var shouldShowInvalidImageIcon: Bool {
        switch imageState {
        case let .failed(blurHash):
            return blurHash != nil
        default:
            return false
        }
    }
}

// MARK: -

public enum LinkPreviewLinkType {
    case preview
    case incomingMessage
    case outgoingMessage
    case incomingMessageGroupInviteLink
    case outgoingMessageGroupInviteLink
}

// MARK: -

public class LinkPreviewLoading: LinkPreviewState {

    public let linkType: LinkPreviewLinkType

    public init(linkType: LinkPreviewLinkType) {
        self.linkType = linkType
    }

    public var isLoaded: Bool { false }

    public var urlString: String? { nil }

    public var displayDomain: String? { return nil }

    public var title: String? { nil }

    public var imageState: LinkPreviewImageState { .none }

    public func imageAsync(thumbnailQuality: AttachmentThumbnailQuality, completion: @escaping (UIImage) -> Void) {
        owsFailDebug("Should not be called.")
    }

    public func imageCacheKey(thumbnailQuality: AttachmentThumbnailQuality) -> LinkPreviewImageCacheKey? {
        owsFailDebug("Should not be called.")
        return nil
    }

    public var imagePixelSize: CGSize { .zero }

    public var previewDescription: String? { nil }

    public var date: Date? { nil }

    public var isGroupInviteLink: Bool {
        switch linkType {
        case .incomingMessageGroupInviteLink, .outgoingMessageGroupInviteLink:
            return true
        default:
            return false
        }
    }

    public var isCallLink: Bool { false }

    public var conversationStyle: ConversationStyle? { nil }
}

// MARK: -

public class LinkPreviewDraft: LinkPreviewState {

    let linkPreviewDraft: OWSLinkPreviewDraft

    public init(linkPreviewDraft: OWSLinkPreviewDraft) {
        self.linkPreviewDraft = linkPreviewDraft
    }

    public var isLoaded: Bool { true }

    public var urlString: String? { linkPreviewDraft.urlString }

    public var displayDomain: String? {
        guard let displayDomain = linkPreviewDraft.displayDomain else {
            owsFailDebug("Missing display domain")
            return nil
        }
        return displayDomain
    }

    public var title: String? { linkPreviewDraft.title?.nilIfEmpty }

    public var imageState: LinkPreviewImageState { linkPreviewDraft.imageData != nil ? .loaded : .none }

    public func imageAsync(thumbnailQuality: AttachmentThumbnailQuality, completion: @escaping (UIImage) -> Void) {
        owsAssertDebug(imageState == .loaded)
        guard let imageData = linkPreviewDraft.imageData else {
            owsFailDebug("Missing imageData.")
            return
        }
        DispatchQueue.global().async {
            guard let image = UIImage(data: imageData) else {
                owsFailDebug("Could not load image: \(imageData.count)")
                return
            }
            completion(image)
        }
    }

    public func imageCacheKey(thumbnailQuality: AttachmentThumbnailQuality) -> LinkPreviewImageCacheKey? {
        guard let urlString else {
            owsFailDebug("Missing urlString.")
            return nil
        }
        return .init(id: nil, urlString: urlString, thumbnailQuality: thumbnailQuality)
    }

    private let imagePixelSizeCache = AtomicOptional<CGSize>(nil, lock: .sharedGlobal)

    public var imagePixelSize: CGSize {
        if let cachedValue = imagePixelSizeCache.get() {
            return cachedValue
        }
        owsAssertDebug(imageState == .loaded)
        guard let imageData = linkPreviewDraft.imageData else {
            owsFailDebug("Missing imageData.")
            return .zero
        }
        let imageMetadata = DataImageSource(imageData).imageMetadata()
        guard let imageMetadata else {
            owsFailDebug("Invalid image.")
            return .zero
        }
        let imagePixelSize = imageMetadata.pixelSize
        guard
            imagePixelSize.width > 0,
            imagePixelSize.height > 0
        else {
            owsFailDebug("Invalid image size.")
            return .zero
        }
        let result = imagePixelSize
        imagePixelSizeCache.set(result)
        return result
    }

    public var previewDescription: String? { linkPreviewDraft.previewDescription }

    public var date: Date? { linkPreviewDraft.date }

    public var isGroupInviteLink: Bool { false }

    public var isCallLink: Bool { false }

    public var conversationStyle: ConversationStyle? { nil }
}

// MARK: -

public class LinkPreviewSent: LinkPreviewState {

    private let linkPreview: OWSLinkPreview
    private let imageAttachment: ReferencedAttachment?
    private let isFailedImageAttachmentDownload: Bool

    public let conversationStyle: ConversationStyle?

    public init(
        linkPreview: OWSLinkPreview,
        imageAttachment: ReferencedAttachment?,
        isFailedImageAttachmentDownload: Bool,
        conversationStyle: ConversationStyle?,
    ) {
        self.linkPreview = linkPreview
        self.imageAttachment = imageAttachment
        self.isFailedImageAttachmentDownload = isFailedImageAttachmentDownload
        self.conversationStyle = conversationStyle
    }

    public var isLoaded: Bool { true }

    public var urlString: String? {
        guard let urlString = linkPreview.urlString else {
            owsFailDebug("Missing url")
            return nil
        }
        return urlString
    }

    public var displayDomain: String? {
        guard let displayDomain = linkPreview.displayDomain else {
            Logger.error("Missing display domain")
            return nil
        }
        return displayDomain
    }

    public var title: String? { linkPreview.title?.filterForDisplay.nilIfEmpty }

    public var imageState: LinkPreviewImageState {
        guard let imageAttachment else {
            return .none
        }
        guard let attachmentStream = imageAttachment.attachment.asStream() else {
            if let blurHash = imageAttachment.attachment.blurHash {
                if isFailedImageAttachmentDownload {
                    return .failed(blurHash: blurHash)
                } else {
                    return .loading(blurHash: blurHash)
                }
            } else {
                return .none
            }
        }
        switch attachmentStream.contentType {
        case .image, .animatedImage:
            break
        default:
            return .invalid
        }
        return .loaded
    }

    public func imageAsync(thumbnailQuality: AttachmentThumbnailQuality, completion: @escaping (UIImage) -> Void) {
        switch imageState {
        case .none, .invalid:
            owsFailDebug("Unexpected image state")
        case let .loading(blurHash), let .failed(blurHash):
            DispatchQueue.global().async {
                guard let blurHash else { return }
                guard let image = BlurHash.image(for: blurHash) else {
                    owsFailDebug("Could not load blurHash")
                    return
                }
                completion(image)
            }
        case .loaded:
            guard let attachmentStream = imageAttachment?.attachment.asStream() else {
                owsFailDebug("Could not load image.")
                return
            }
            DispatchQueue.global().async {
                switch attachmentStream.contentType {
                case .animatedImage:
                    guard let image = try? attachmentStream.decryptedSDAnimatedImage() else {
                        owsFailDebug("Could not load image")
                        return
                    }
                    completion(image)
                case .image:
                    Task {
                        guard let image = await attachmentStream.thumbnailImage(quality: thumbnailQuality) else {
                            owsFailDebug("Could not load thumnail.")
                            return
                        }
                        completion(image)
                    }
                default:
                    owsFailDebug("Invalid image.")
                    return
                }
            }
        }
    }

    public func imageCacheKey(thumbnailQuality: AttachmentThumbnailQuality) -> LinkPreviewImageCacheKey? {
        guard let imageAttachment else { return nil }
        guard let attachmentStream = imageAttachment.attachment.asStream() else {
            return .init(
                id: imageAttachment.attachment.id,
                urlString: nil,
                isBlurHash: true,
                thumbnailQuality: thumbnailQuality,
            )
        }
        return .init(id: attachmentStream.id, urlString: nil, thumbnailQuality: thumbnailQuality)
    }

    public var imagePixelSize: CGSize {
        switch imageState {
        case .none, .invalid:
            owsFailDebug("Unexpected image state")
            return .zero
        case let .loading(blurHash), let .failed(blurHash):
            guard blurHash != nil else { return .zero }
            return imageAttachment?.reference.sourceMediaSizePixels
                // Fall back to default size to render the blurhash in.
                ?? CGSize(width: 400, height: 236)
        case .loaded:
            guard let attachmentStream = imageAttachment?.attachment.asStream() else {
                return CGSize.zero
            }

            switch attachmentStream.contentType {
            case .image(let pixelSize):
                return pixelSize
            case .animatedImage(let pixelSize):
                return pixelSize
            case .audio, .video, .file, .invalid:
                return .zero
            }
        }
    }

    public var previewDescription: String? { linkPreview.previewDescription }

    public var date: Date? { linkPreview.date }

    public var isGroupInviteLink: Bool { false }

    public var isCallLink: Bool { false }
}