Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/MediaGallery/Cells/MediaGalleryFileCell.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import LibSignalClient
import PassKit
import SignalServiceKit
import SignalUI

class MediaGalleryFileCell: MediaTileListModeCell {

    static let reuseIdentifier = "MediaGalleryFileCell"

    private var attachment: ReferencedAttachmentStream?
    private var receivedAtDate: Date?
    private var owningMessage: TSMessage?
    private var mediaMetadata: MediaMetadata?

    private var fileItem: MediaGalleryCellItemOtherFile? {
        didSet {
            guard let fileItem else {
                attachment = nil
                receivedAtDate = nil
                owningMessage = nil
                mediaMetadata = nil
                return
            }
            attachment = fileItem.attachmentStream
            receivedAtDate = fileItem.receivedAtDate
            owningMessage = fileItem.message
            mediaMetadata = fileItem.metadata
        }
    }

    class var defaultCellHeight: CGFloat { 88 }

    private static let contentInset = UIEdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 10)

    override class var contentCardVerticalInset: CGFloat { 6 }

    private static var cellHeights: [UIContentSizeCategory: CGFloat] = [:]

    class func cellHeight(for item: MediaGalleryCellItem, maxWidth: CGFloat) -> CGFloat {
        switch item {
        case .otherFile:
            break
        case .photoVideo, .audio:
            owsFailDebug("Unexpected item type")
            return defaultCellHeight
        }

        let currentContentSizeCategory = UITraitCollection.current.preferredContentSizeCategory

        if let cellHeight: CGFloat = {
            return cellHeights[currentContentSizeCategory]
        }() {
            return cellHeight
        }

        guard let attachment = item.attachmentStream else {
            return defaultCellHeight
        }
        let genericAttachment = CVComponentState.GenericAttachment(
            attachment: .stream(attachment, isUploading: false),
        )

        let genericAttachmentViewSize = CVComponentGenericAttachment.measure(
            maxWidth: maxWidth,
            measurementBuilder: CVCellMeasurement.Builder(),
            genericAttachment: genericAttachment,
        )

        let cellHeight = genericAttachmentViewSize.height + Self.contentInset.totalHeight + 2 * Self.contentCardVerticalInset
        cellHeights[currentContentSizeCategory] = cellHeight

        return cellHeight
    }

    private lazy var tapGestureRecognizer: UITapGestureRecognizer = {
        let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture))
        gestureRecognizer.delegate = self
        return gestureRecognizer
    }()

    private var itemModel: CVItemModel?

    private var genericAttachmentView: CVComponentView?

    private let genericAttachmentContainerView: UIView = {
        let view = UIView.container()
        view.backgroundColor = UIColor(dynamicProvider: { _ in Theme.tableCell2PresentedBackgroundColor })
        view.translatesAutoresizingMaskIntoConstraints = false
        view.layer.cornerRadius = 10
        view.layer.masksToBounds = true
        return view
    }()

    private func createGenericAttachmentView(transaction: DBReadTransaction) {
        owsAssertDebug(genericAttachmentView == nil)

        guard let fileItem else {
            owsFailDebug("fileItem not set")
            return
        }

        guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)?.aci else {
            owsFailDebug("User not registered")
            return
        }

        let threadAssociatedData = ThreadAssociatedData.fetchOrDefault(for: fileItem.thread, transaction: transaction)
        // Make an itemModel which is needed to play the audio file.
        // This is only used to save the playback rate, which is kind of nuts.
        let threadViewModel = ThreadViewModel(
            thread: fileItem.thread,
            forChatList: false,
            transaction: transaction,
        )
        let conversationStyle = ConversationStyle(
            type: .default,
            thread: fileItem.thread,
            viewWidth: contentView.bounds.width,
            hasWallpaper: false,
            shouldDimWallpaperInDarkMode: false,
            isWallpaperPhoto: false,
            chatColor: ChatColorSettingStore.Constants.defaultColor.colorSetting,
        )
        let coreState = CVCoreState(conversationStyle: conversationStyle, mediaCache: fileItem.mediaCache)
        let viewStateSnapshot = CVViewStateSnapshot.mockSnapshotForStandaloneItems(
            coreState: coreState,
            spoilerReveal: spoilerState.revealState,
        )
        let itemBuildingContext = CVItemBuildingContextImpl(
            prevRenderState: nil,
            threadViewModel: threadViewModel,
            viewStateSnapshot: viewStateSnapshot,
            transaction: transaction,
            avatarBuilder: CVAvatarBuilder(transaction: transaction),
            localAci: localAci,
        )
        guard
            let componentState = try? CVComponentState.build(
                interaction: fileItem.interaction,
                itemBuildingContext: itemBuildingContext,
            )
        else {
            return
        }
        let itemViewState = CVItemViewState.Builder()
        itemViewState.audioPlaybackRate = threadAssociatedData.audioPlaybackRate
        let itemModel = CVItemModel(
            interaction: fileItem.interaction,
            thread: fileItem.thread,
            threadAssociatedData: threadAssociatedData,
            componentState: componentState,
            itemViewState: itemViewState.build(),
            coreState: coreState,
        )
        let genericAttachment = CVComponentState.GenericAttachment(attachment: .stream(
            fileItem.attachmentStream,
            isUploading: false,
        ))
        let component = CVComponentGenericAttachment(
            itemModel: itemModel,
            genericAttachment: genericAttachment,
        )
        // Always treat as incoming so we get the right colors.
        component.isIncomingOverride = true
        let view = component.buildComponentView(componentDelegate: self)
        view.rootView.translatesAutoresizingMaskIntoConstraints = false

        let measurementBuilder = CVCellMeasurement.Builder()
        measurementBuilder.cellSize = CVComponentGenericAttachment.measure(
            maxWidth: contentView.bounds.width, // actual max width doesn't matter because there's no multiline text
            measurementBuilder: measurementBuilder,
            genericAttachment: genericAttachment,
        )
        let cellMeasurement = measurementBuilder.build()
        component.configureForRendering(componentView: view, cellMeasurement: cellMeasurement, componentDelegate: self)
        genericAttachmentContainerView.addSubview(view.rootView)
        view.rootView.autoPinEdgesToSuperviewEdges(with: Self.contentInset)

        self.itemModel = itemModel
        self.genericAttachmentView = view
    }

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

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setupViews() {
        contentView.addSubview(genericAttachmentContainerView)
        NSLayoutConstraint.activate([
            genericAttachmentContainerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Self.contentCardVerticalInset),
            genericAttachmentContainerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Self.contentCardVerticalInset),
            genericAttachmentContainerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -OWSTableViewController2.defaultHOuterMargin),
        ])

        let constraintWithSelectionButton = genericAttachmentContainerView.leadingAnchor.constraint(
            equalTo: selectionButton.trailingAnchor,
            constant: 12,
        )
        let constraintWithoutSelectionButton = genericAttachmentContainerView.leadingAnchor.constraint(
            equalTo: contentView.leadingAnchor,
            constant: OWSTableViewController2.defaultHOuterMargin,
        )

        addGestureRecognizer(tapGestureRecognizer)

        super.setupViews(
            constraintWithSelectionButton: constraintWithSelectionButton,
            constraintWithoutSelectionButton: constraintWithoutSelectionButton,
        )
    }

    @objc
    private func handleTapGesture(_ sender: UITapGestureRecognizer) {
        guard let fileItem, let itemModel else {
            return
        }
        let genericAttachment = CVComponentGenericAttachment(
            itemModel: itemModel,
            genericAttachment: .init(attachment: .stream(
                fileItem.attachmentStream,
                isUploading: false,
            )),
        )
        if
            PKAddPassesViewController.canAddPasses(),
            let pkPass = genericAttachment.representedPKPass(),
            let addPassesVC = PKAddPassesViewController(pass: pkPass)
        {
            CurrentAppContext().frontmostViewController()?.present(addPassesVC, animated: true, completion: nil)
            return
        } else if let previewController = genericAttachment.createQLPreviewController() {
            CurrentAppContext().frontmostViewController()?.present(previewController, animated: true, completion: nil)
            return
        }
    }

    private func setUpAccessibility(item: MediaGalleryCellItemAudio?) {
        isAccessibilityElement = true

        if let fileItem {
            accessibilityLabel = [
                fileItem.localizedString,
                MediaTileDateFormatter.formattedDateString(for: fileItem.receivedAtDate),
            ]
            .compactMap { $0 }
            .joined(separator: ", ")
        } else {
            accessibilityLabel = ""
        }
    }

    override var cellsAbut: Bool { false }

    private(set) var spoilerState = SpoilerRenderState()

    override func configure(item: MediaGalleryCellItem, spoilerState: SpoilerRenderState) {
        super.configure(item: item, spoilerState: spoilerState)

        guard case let .otherFile(fileItem) = item else {
            owsFailDebug("Unexpected item type")
            return
        }
        self.fileItem = fileItem
        self.spoilerState = spoilerState

        if let genericAttachmentView {
            genericAttachmentView.rootView.removeFromSuperview()
            self.genericAttachmentView = nil
        }

        SSKEnvironment.shared.databaseStorageRef.read { transaction in
            createGenericAttachmentView(transaction: transaction)
        }
    }

    override func makePlaceholder() {
        genericAttachmentView?.rootView.removeFromSuperview()
        genericAttachmentView = nil
    }

    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        if let item {
            configure(item: item, spoilerState: spoilerState)
        }
    }

    override func setAllowsMultipleSelection(_ allowed: Bool, animated: Bool) {
        tapGestureRecognizer.isEnabled = !allowed
        super.setAllowsMultipleSelection(allowed, animated: animated)
    }
}

extension MediaGalleryFileCell: UIGestureRecognizerDelegate {

    override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        return !allowsMultipleSelection
    }
}

extension MediaGalleryFileCell: CVComponentDelegate {

    var view: UIView! {
        return self
    }

    func enqueueReload() {}

    func enqueueReloadWithoutCaches() {}

    func didTapBodyTextItem(_ item: CVTextLabel.Item) {}

    func didLongPressBodyTextItem(_ item: CVTextLabel.Item) {}

    func didTapSystemMessageItem(_ item: CVTextLabel.Item) {}

    func didTapCollapseSet(collapseSetId: String) {}

    func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {}

    func didLongPressTextViewItem(
        _ cell: CVCell,
        itemViewModel: CVItemViewModelImpl,
        shouldAllowReply: Bool,
    ) {}

    func didLongPressMediaViewItem(
        _ cell: CVCell,
        itemViewModel: CVItemViewModelImpl,
        shouldAllowReply: Bool,
    ) {}

    func didLongPressQuote(
        _ cell: CVCell,
        itemViewModel: CVItemViewModelImpl,
        shouldAllowReply: Bool,
    ) {}

    func didLongPressSystemMessage(
        _ cell: CVCell,
        itemViewModel: CVItemViewModelImpl,
    ) {}

    func didLongPressSticker(
        _ cell: CVCell,
        itemViewModel: CVItemViewModelImpl,
        shouldAllowReply: Bool,
    ) {}

    func didLongPressPaymentMessage(
        _ cell: CVCell,
        itemViewModel: CVItemViewModelImpl,
        shouldAllowReply: Bool,
    ) {}

    func didLongPressPoll(
        _ cell: CVCell,
        itemViewModel: CVItemViewModelImpl,
        shouldAllowReply: Bool,
    ) {}

    func didTapPayment(_ payment: PaymentsHistoryItem) {}

    func didChangeLongPress(_ itemViewModel: CVItemViewModelImpl) {}

    func didEndLongPress(_ itemViewModel: CVItemViewModelImpl) {}

    func didCancelLongPress(_ itemViewModel: CVItemViewModelImpl) {}

    // MARK: -

    func willBecomeVisibleWithSkippedDownloads(_ message: TSMessage) {}

    func didTapSkippedDownloads(_ message: TSMessage) {}

    func didCancelDownload(_ message: TSMessage, attachmentId: Attachment.IDType) {}

    // MARK: -

    func didTapReplyToItem(_ itemViewModel: CVItemViewModelImpl) {}

    func didTapSenderAvatar(_ interaction: TSInteraction) {}

    func shouldAllowReplyForItem(_ itemViewModel: CVItemViewModelImpl) -> Bool { false }

    func didTapReactions(
        reactionState: InteractionReactionState,
        message: TSMessage,
    ) {}

    func didTapTruncatedTextMessage(_ itemViewModel: CVItemViewModelImpl) {}

    func didTapShowEditHistory(_ itemViewModel: CVItemViewModelImpl) {}

    var hasPendingMessageRequest: Bool { false }

    func didTapUndownloadableMedia() {}

    func didTapUndownloadableGenericFile() {}

    func didTapUndownloadableOversizeText() {}

    func didTapUndownloadableAudio() {}

    func didTapUndownloadableSticker() {}

    func didTapBrokenVideo() {}

    func didTapBodyMedia(
        itemViewModel: CVItemViewModelImpl,
        attachmentStream: ReferencedAttachmentStream,
        imageView: UIView,
    ) {}

    func didTapGenericAttachment(
        _ attachment: CVComponentGenericAttachment,
    ) -> CVAttachmentTapAction { .default }

    func didTapQuotedReply(_ quotedReply: QuotedReplyModel) {}

    func didTapLinkPreview(url: URL) {}

    func didTapContactShare(_ contactShare: ContactShareViewModel) {}

    func didTapSendMessage(to phoneNumbers: [String]) {}

    func didTapSendInvite(toContactShare contactShare: ContactShareViewModel) {}

    func didTapAddToContacts(contactShare: ContactShareViewModel) {}

    func didTapStickerPack(_ stickerPackInfo: StickerPackInfo) {}

    func didTapGroupInviteLink(url: URL) {}

    func didTapProxyLink(url: URL) {}

    func didTapShowMessageDetail(_ itemViewModel: CVItemViewModelImpl) {}

    func willWrapGift(_ messageUniqueId: String) -> Bool { false }

    func willShakeGift(_ messageUniqueId: String) -> Bool { false }

    func willUnwrapGift(_ itemViewModel: CVItemViewModelImpl) {}

    func didTapGiftBadge(
        _ itemViewModel: CVItemViewModelImpl,
        profileBadge: ProfileBadge,
        isExpired: Bool,
        isRedeemed: Bool,
    ) {}

    func prepareMessageDetailForInteractivePresentation(_ itemViewModel: CVItemViewModelImpl) {}

    func beginCellAnimation(maximumDuration: TimeInterval) -> EndCellAnimation {
        return {}
    }

    var wallpaperBlurProvider: WallpaperBlurProvider? { nil }

    var selectionState: CVSelectionState { CVSelectionState() }

    func didTapPreviouslyVerifiedIdentityChange(_ address: SignalServiceAddress) {}

    func didTapUnverifiedIdentityChange(_ address: SignalServiceAddress) {}

    func didTapCorruptedMessage(_ message: TSErrorMessage) {}

    func didTapSessionRefreshMessage(_ message: TSErrorMessage) {}

    func didTapResendGroupUpdateForErrorMessage(_ errorMessage: TSErrorMessage) {}

    func didTapShowFingerprint(_ address: SignalServiceAddress) {}

    func didTapIndividualCall(_ call: TSCall) {}

    func didTapLearnMoreMissedCallFromBlockedContact(_ call: TSCall) {}

    func didTapGroupCall() {}

    func didTapPendingOutgoingMessage(_ message: TSOutgoingMessage) {}

    func didTapFailedMessage(_ message: TSMessage) {}

    func didTapGroupMigrationLearnMore() {}

    func didTapGroupInviteLinkPromotion(groupModel: TSGroupModel) {}

    func didTapViewGroupDescription(newGroupDescription: String) {}

    func didTapNameEducation(type: SafetyTipsType) {}

    func didTapShowConversationSettings() {}

    func didTapShowConversationSettingsAndShowMemberRequests() {}

    func didTapBlockRequest(
        groupModel: TSGroupModelV2,
        requesterName: String,
        requesterAci: Aci,
    ) {}

    func didTapShowUpgradeAppUI() {}

    func didTapUpdateSystemContact(
        _ address: SignalServiceAddress,
        newNameComponents: PersonNameComponents,
    ) {}

    func didTapPhoneNumberChange(aci: Aci, phoneNumberOld: String, phoneNumberNew: String) {}

    func didTapViewOnceAttachment(_ interaction: TSInteraction) {}

    func didTapViewOnceExpired(_ interaction: TSInteraction) {}

    func didTapContactName(thread: TSContactThread) {}

    func didTapUnknownThreadWarningGroup() {}
    func didTapUnknownThreadWarningContact() {}
    func didTapDeliveryIssueWarning(_ message: TSErrorMessage) {}

    func didTapActivatePayments() {}
    func didTapSendPayment() {}

    func didTapThreadMergeLearnMore(phoneNumber: String) {}

    func didTapReportSpamLearnMore() {}

    func didTapMessageRequestAcceptedOptions() {}

    func didTapJoinCallLinkCall(callLink: CallLink) {}

    func didTapViewVotes(poll: OWSPoll) {}

    func didTapViewPoll(pollInteractionUniqueId: String) {}

    func didTapVoteOnPoll(poll: OWSPoll, optionIndex: UInt32, isUnvote: Bool) {}

    func didTapViewPinnedMessage(pinnedMessageUniqueId: String) {}

    func didTapSafetyTips() {}
}