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

public import SignalServiceKit
public import UIKit

// TODO: This will be part of our reuse strategy.
// We'll probably want to have reuse identifiers
// that correspond to certain CVComponentState common
// variations, e.g.:
//
// * Text-only message with optional sender name + footer.
// * Media message with optional text + sender name + footer.
public enum CVCellReuseIdentifier: String, CaseIterable {
    case `default`
    case dateHeader
    case unreadIndicator
    case typingIndicator
    case threadDetails
    case systemMessage
    case unknownThreadWarning
    case collapseSet
}

// MARK: -

public class CVCell: UICollectionViewCell, CVRootComponentHost {

    public var isCellVisible: Bool = false {
        didSet {
            componentView?.setIsCellVisible(isCellVisible)
        }
    }

    public var renderItem: CVRenderItem?
    public var componentView: CVComponentView?
    public var hostView: UIView { contentView }
    public var rootComponent: CVRootComponent? { renderItem?.rootComponent }

    private var messageSwipeActionState: CVMessageSwipeActionState?

    override init(frame: CGRect) {
        super.init(frame: frame)

        layoutMargins = .zero
        contentView.layoutMargins = .zero
    }

    @available(*, unavailable, message: "Unimplemented")
    public required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    public static func registerReuseIdentifiers(collectionView: UICollectionView) {
        for value in CVCellReuseIdentifier.allCases {
            collectionView.register(self, forCellWithReuseIdentifier: value.rawValue)
        }
    }

    override public func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize {
        guard let renderItem else {
            owsFailDebug("Missing renderItem.")
            return super.systemLayoutSizeFitting(targetSize)
        }
        let cellSize = renderItem.cellSize
        if cellSize.width > targetSize.width || cellSize.height > targetSize.height {
            // This can happen due to races or incorrect initial view size on iPad.
            Logger.verbose("Unexpected cellSize: \(cellSize), targetSize: \(targetSize)")
        }
        return targetSize
    }

    override public func systemLayoutSizeFitting(
        _ targetSize: CGSize,
        withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority,
        verticalFittingPriority: UILayoutPriority,
    ) -> CGSize {
        guard let renderItem else {
            owsFailDebug("Missing renderItem.")
            return super.systemLayoutSizeFitting(
                targetSize,
                withHorizontalFittingPriority: horizontalFittingPriority,
                verticalFittingPriority: verticalFittingPriority,
            )
        }
        let cellSize = renderItem.cellSize
        if cellSize.width > targetSize.width || cellSize.height > targetSize.height {
            // This can happen due to races or incorrect initial view size on iPad.
            Logger.verbose("Unexpected cellSize: \(cellSize), targetSize: \(targetSize)")
        }
        return targetSize
    }

    // For perf reasons, skip the default implementation which is only relevant for self-sizing cells.
    override public func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
        layoutAttributes
    }

    private var lastLayoutAttributes: CVCollectionViewLayoutAttributes?

    override public func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
        super.apply(layoutAttributes)

        guard let layoutAttributes = layoutAttributes as? CVCollectionViewLayoutAttributes else {
            owsFailDebug("Could not apply layoutAttributes.")
            return
        }

        lastLayoutAttributes = layoutAttributes

        applyLastLayoutAttributes()
    }

    func configure(
        renderItem: CVRenderItem,
        componentDelegate: CVComponentDelegate,
        messageSwipeActionState: CVMessageSwipeActionState,
    ) {

        let isReusingDedicatedCell = componentView != nil && renderItem.rootComponent.isDedicatedCell

        if !isReusingDedicatedCell {
            layoutMargins = .zero
            contentView.layoutMargins = .zero
        }

        configureForHosting(
            renderItem: renderItem,
            componentDelegate: componentDelegate,
            messageSwipeActionState: messageSwipeActionState,
        )

        self.messageSwipeActionState = messageSwipeActionState

        applyLastLayoutAttributes()
    }

    private func applyLastLayoutAttributes() {

        guard let layoutAttributes = self.lastLayoutAttributes else {
            return
        }

        guard
            let rootComponent = self.rootComponent,
            let componentView = self.componentView
        else {
            return
        }

        rootComponent.apply(
            layoutAttributes: layoutAttributes,
            componentView: componentView,
        )
    }

    override public func prepareForReuse() {
        super.prepareForReuse()

        var isDedicatedCell = false
        if let rootComponent = self.rootComponent {
            isDedicatedCell = rootComponent.isDedicatedCell
        } else {
            owsFailDebug("Missing rootComponent.")
        }

        renderItem = nil

        if !isDedicatedCell {
            contentView.removeAllSubviews()
        }

        if let componentView {
            componentView.reset()
        } else {
            owsFailDebug("Missing componentView.")
        }

        isCellVisible = false
        messageSwipeActionState = nil
        lastLayoutAttributes = nil
        layer.zPosition = 0
    }
}

// MARK: -

// This view hosts the cell contents.
// This allows us to display message cells outside of
// UICollectionView, e.g. in the message details view.
public class CVCellView: UIView, CVRootComponentHost {

    public var isCellVisible: Bool = false {
        didSet {
            componentView?.setIsCellVisible(isCellVisible)
        }
    }

    public var renderItem: CVRenderItem?
    public var componentView: CVComponentView?
    public var hostView: UIView { self }
    public var rootComponent: CVRootComponent? { renderItem?.rootComponent }

    init() {
        super.init(frame: .zero)
    }

    public func configure(
        renderItem: CVRenderItem,
        componentDelegate: CVComponentDelegate,
    ) {

        self.layoutMargins = .zero

        let messageSwipeActionState = CVMessageSwipeActionState()
        configureForHosting(
            renderItem: renderItem,
            componentDelegate: componentDelegate,
            messageSwipeActionState: messageSwipeActionState,
        )
        owsAssertDebug(componentView != nil)
    }

    @available(*, unavailable, message: "Unimplemented")
    public required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func reset() {
        renderItem = nil

        removeAllSubviews()

        if let componentView {
            componentView.reset()
        }
    }
}

// MARK: -

public protocol CVRootComponentHost: AnyObject {
    var renderItem: CVRenderItem? { get set }
    var componentView: CVComponentView? { get set }
    var rootComponent: CVRootComponent? { get }
    var hostView: UIView { get }
    var isCellVisible: Bool { get }
}

// MARK: -

public extension CVRootComponentHost {
    fileprivate func configureForHosting(
        renderItem: CVRenderItem,
        componentDelegate: CVComponentDelegate,
        messageSwipeActionState: CVMessageSwipeActionState,
    ) {
        self.renderItem = renderItem

#if TESTABLE_BUILD
        GRDBDatabaseStorageAdapter.canOpenTransaction = false
#endif

        let rootComponent = renderItem.rootComponent

        let componentView: CVComponentView
        if let componentViewForReuse = self.componentView {
            componentView = componentViewForReuse
        } else {
            componentView = rootComponent.buildComponentView(componentDelegate: componentDelegate)
        }
        self.componentView = componentView
        componentView.setIsCellVisible(isCellVisible)

        componentView.isDedicatedCellView = rootComponent.isDedicatedCell

        rootComponent.configureCellRootComponent(
            cellView: hostView,
            cellMeasurement: renderItem.cellMeasurement,
            componentDelegate: componentDelegate,
            messageSwipeActionState: messageSwipeActionState,
            componentView: componentView,
        )

#if TESTABLE_BUILD
        GRDBDatabaseStorageAdapter.canOpenTransaction = true
#endif
    }

    func handleTap(sender: UIGestureRecognizer, componentDelegate: CVComponentDelegate) -> Bool {
        guard let renderItem else {
            owsFailDebug("Missing renderItem.")
            return false
        }
        guard let componentView else {
            owsFailDebug("Missing componentView.")
            return false
        }
        return renderItem.rootComponent.handleTap(
            sender: sender,
            componentDelegate: componentDelegate,
            componentView: componentView,
            renderItem: renderItem,
        )
    }

    func canHandleDoubleTap(sender: UIGestureRecognizer, componentDelegate: any CVComponentDelegate) -> Bool {
        guard let componentView else {
            owsFailDebug("Missing componentView.")
            return false
        }

        // Can the _view_ handle the double tap?
        guard componentView.canHandleDoubleTapGesture?(sender) == true else {
            return false
        }

        guard let renderItem else {
            owsFailDebug("Missing renderItem.")
            return false
        }
        // Can the _contents_ handle the double tap?
        return renderItem.rootComponent.canHandleDoubleTap(sender: sender, componentDelegate: componentDelegate, renderItem: renderItem)
    }

    func handleDoubleTap(sender: UIGestureRecognizer, componentDelegate: any CVComponentDelegate) -> Bool {
        guard canHandleDoubleTap(sender: sender, componentDelegate: componentDelegate) else {
            return false
        }
        guard let renderItem else {
            owsFailDebug("Missing renderItem.")
            return false
        }
        return renderItem.rootComponent.handleDoubleTap(sender: sender, componentDelegate: componentDelegate, renderItem: renderItem)
    }

    func findLongPressHandler(
        sender: UIGestureRecognizer,
        componentDelegate: CVComponentDelegate,
    ) -> CVLongPressHandler? {
        guard let renderItem else {
            owsFailDebug("Missing renderItem.")
            return nil
        }
        guard let componentView else {
            owsFailDebug("Missing componentView.")
            return nil
        }
        return renderItem.rootComponent.findLongPressHandler(
            sender: sender,
            componentDelegate: componentDelegate,
            componentView: componentView,
            renderItem: renderItem,
        )
    }

    func findPanHandler(
        sender: UIPanGestureRecognizer,
        componentDelegate: CVComponentDelegate,
        messageSwipeActionState: CVMessageSwipeActionState,
    ) -> CVPanHandler? {
        guard let renderItem else {
            owsFailDebug("Missing renderItem.")
            return nil
        }
        guard let componentView else {
            owsFailDebug("Missing componentView.")
            return nil
        }
        return renderItem.rootComponent.findPanHandler(
            sender: sender,
            componentDelegate: componentDelegate,
            componentView: componentView,
            renderItem: renderItem,
            messageSwipeActionState: messageSwipeActionState,
        )
    }

    func startPanGesture(
        sender: UIPanGestureRecognizer,
        panHandler: CVPanHandler,
        componentDelegate: CVComponentDelegate,
        messageSwipeActionState: CVMessageSwipeActionState,
    ) {
        guard let renderItem else {
            owsFailDebug("Missing renderItem.")
            return
        }
        guard let componentView else {
            owsFailDebug("Missing componentView.")
            return
        }
        renderItem.rootComponent.startPanGesture(
            sender: sender,
            panHandler: panHandler,
            componentDelegate: componentDelegate,
            componentView: componentView,
            renderItem: renderItem,
            messageSwipeActionState: messageSwipeActionState,
        )
    }

    func handlePanGesture(
        sender: UIPanGestureRecognizer,
        panHandler: CVPanHandler,
        componentDelegate: CVComponentDelegate,
        messageSwipeActionState: CVMessageSwipeActionState,
    ) {
        guard let renderItem else {
            owsFailDebug("Missing renderItem.")
            return
        }
        guard let componentView else {
            owsFailDebug("Missing componentView.")
            return
        }
        renderItem.rootComponent.handlePanGesture(
            sender: sender,
            panHandler: panHandler,
            componentDelegate: componentDelegate,
            componentView: componentView,
            renderItem: renderItem,
            messageSwipeActionState: messageSwipeActionState,
        )
    }

    func albumItemView(forAttachment attachment: ReferencedAttachment) -> UIView? {
        guard let renderItem else {
            owsFailDebug("Missing renderItem.")
            return nil
        }
        guard let componentView else {
            owsFailDebug("Missing componentView.")
            return nil
        }
        guard let messageComponent = renderItem.rootComponent as? CVComponentMessage else {
            owsFailDebug("Invalid rootComponent.")
            return nil
        }
        return messageComponent.albumItemView(
            forAttachment: attachment,
            componentView: componentView,
        )
    }

    func updateScrollingContent() {
        guard
            let rootComponent,
            let componentView
        else {
            owsFailDebug("Missing component.")
            return
        }
        rootComponent.updateScrollingContent(componentView: componentView)
    }
}