// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import PassKit import QuickLook import SignalServiceKit import SignalUI public class CVComponentGenericAttachment: CVComponentBase, CVComponent { public var componentKey: CVComponentKey { .genericAttachment } private let genericAttachment: CVComponentState.GenericAttachment private var attachment: ReferencedTSResource { genericAttachment.attachment.attachment } private var attachmentStream: TSResourceStream? { genericAttachment.attachmentStream } private var attachmentPointer: TSResourcePointer? { genericAttachment.attachmentPointer } init(itemModel: CVItemModel, genericAttachment: CVComponentState.GenericAttachment) { self.genericAttachment = genericAttachment super.init(itemModel: itemModel) } deinit { // Wipe the value so we delete the file self.qlPreviewTmpFileUrl = nil } public func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView { CVComponentViewGenericAttachment() } public func configureForRendering(componentView componentViewParam: CVComponentView, cellMeasurement: CVCellMeasurement, componentDelegate: CVComponentDelegate) { guard let componentView = componentViewParam as? CVComponentViewGenericAttachment else { owsFailDebug("Unexpected componentView.") componentViewParam.reset() return } let hStackView = componentView.hStackView let vStackView = componentView.vStackView var hSubviews = [UIView]() if let downloadView = tryToBuildProgressView() { hSubviews.append(downloadView) } else { let iconImageView = componentView.iconImageView if let icon = UIImage(named: "generic-attachment") { owsAssertDebug(icon.size == iconSize) iconImageView.image = icon } else { owsFailDebug("Missing icon.") } hSubviews.append(iconImageView) let fileTypeLabel = componentView.fileTypeLabel fileTypeLabelConfig.applyForRendering(label: fileTypeLabel) fileTypeLabel.adjustsFontSizeToFitWidth = true fileTypeLabel.minimumScaleFactor = 0.25 fileTypeLabel.textAlignment = .center // Center on icon. iconImageView.addSubview(fileTypeLabel) vStackView.addLayoutBlock { _ in guard let superview = fileTypeLabel.superview else { owsFailDebug("Missing superview.") return } var labelSize = fileTypeLabel.sizeThatFitsMaxSize labelSize.width = min(labelSize.width, superview.bounds.width - 15) let labelFrame = CGRect(origin: ((superview.bounds.size - labelSize) * 0.5).asPoint, size: labelSize) fileTypeLabel.frame = labelFrame } } let topLabel = componentView.topLabel let bottomLabel = componentView.bottomLabel topLabelConfig.applyForRendering(label: topLabel) bottomLabelConfig.applyForRendering(label: bottomLabel) let vSubviews = [ componentView.topLabel, componentView.bottomLabel ] vStackView.configure(config: vStackConfig, cellMeasurement: cellMeasurement, measurementKey: Self.measurementKey_vStack, subviews: vSubviews) hSubviews.append(vStackView) hStackView.configure(config: hStackConfig, cellMeasurement: cellMeasurement, measurementKey: Self.measurementKey_hStack, subviews: hSubviews) } private var hStackConfig: CVStackViewConfig { CVStackViewConfig(axis: .horizontal, alignment: .center, spacing: hSpacing, layoutMargins: UIEdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0)) } private var vStackConfig: CVStackViewConfig { CVStackViewConfig(axis: .vertical, alignment: .leading, spacing: labelVSpacing, layoutMargins: .zero) } private var topLabelConfig: CVLabelConfig { var text: String = attachment.reference.sourceFilename?.ows_stripped() ?? "" if text.isEmpty, let fileExtension = MimeTypeUtil.fileExtensionForMimeType(attachment.attachment.mimeType) { text = (fileExtension as NSString).localizedUppercase } if text.isEmpty { text = OWSLocalizedString("GENERIC_ATTACHMENT_LABEL", comment: "A label for generic attachments.") } return CVLabelConfig.unstyledText( text, font: UIFont.dynamicTypeBody2.semibold(), textColor: conversationStyle.bubbleTextColor(isIncoming: isIncoming), lineBreakMode: .byTruncatingMiddle ) } private var bottomLabelConfig: CVLabelConfig { // We don't want to show the file size while the attachment is downloading. // To avoid layout jitter when the download completes, we reserve space in // the layout using a whitespace string. var text = " " if let attachmentPointer = self.genericAttachment.attachmentPointer { var textComponents = [String]() if let byteCount = attachmentPointer.resource.unencryptedResourceByteCount, byteCount > 0 { textComponents.append(OWSFormat.localizedFileSizeString(from: Int64(byteCount))) } switch genericAttachment.attachment { case .stream, .pointer(_, .enqueued), .pointer(_, .downloading): break case .pointer(_, .failed), .pointer(_, .none): textComponents.append(OWSLocalizedString("ACTION_TAP_TO_DOWNLOAD", comment: "A label for 'tap to download' buttons.")) } if !textComponents.isEmpty { text = textComponents.joined(separator: " • ") } } else if let attachmentStream = attachmentStream { if let fileSize = attachmentStream.encryptedResourceByteCount { text = OWSFormat.localizedFileSizeString(from: Int64(fileSize)) } } else { owsFailDebug("Invalid attachment") } return CVLabelConfig.unstyledText( text, font: UIFont.dynamicTypeCaption1, textColor: conversationStyle.bubbleSecondaryTextColor(isIncoming: isIncoming), lineBreakMode: .byTruncatingMiddle ) } private var fileTypeLabelConfig: CVLabelConfig { let filename: String = attachment.reference.sourceFilename ?? "" var fileExtension: String = (filename as NSString).pathExtension if fileExtension.isEmpty { fileExtension = MimeTypeUtil.fileExtensionForMimeType(attachment.attachment.mimeType) ?? "" } let text = (fileExtension as NSString).localizedUppercase return CVLabelConfig.unstyledText( text, font: UIFont.dynamicTypeCaption1.semibold(), textColor: .ows_gray90, lineBreakMode: .byTruncatingTail ) } private func tryToBuildProgressView() -> UIView? { let direction: CVAttachmentProgressView.Direction switch CVAttachmentProgressView.progressType( forAttachment: genericAttachment.attachment, interaction: interaction ) { case .none: return nil case .uploading: // We currently only show progress for downloads here. return nil case .pendingDownload(let attachmentPointer): direction = .download( attachmentPointer: attachmentPointer, transitTierDownloadState: .none ) case .downloading(let attachmentPointer, let transitTierDownloadState): direction = .download( attachmentPointer: attachmentPointer, transitTierDownloadState: transitTierDownloadState ) case .unknown: owsFailDebug("Unknown progress type.") return nil } return CVAttachmentProgressView(direction: direction, diameter: progressSize, isDarkThemeEnabled: conversationStyle.isDarkThemeEnabled, mediaCache: mediaCache) } private var hasProgressView: Bool { switch CVAttachmentProgressView.progressType( forAttachment: genericAttachment.attachment, interaction: interaction ) { case .none, .uploading: // We currently only show progress for downloads here. return false case .pendingDownload, .downloading: return true case .unknown: owsFailDebug("Unknown progress type.") return false } } private let hSpacing: CGFloat = 8 private let labelVSpacing: CGFloat = 1 private let iconSize = CGSize(width: 36, height: CGFloat(AvatarBuilder.standardAvatarSizePoints)) private let progressSize: CGFloat = 36 private static let measurementKey_hStack = "CVComponentGenericAttachment.measurementKey_hStack" private static let measurementKey_vStack = "CVComponentGenericAttachment.measurementKey_vStack" public func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize { owsAssertDebug(maxWidth > 0) let leftViewSize: CGSize = (hasProgressView ? .square(progressSize) : iconSize) let maxLabelWidth = max(0, maxWidth - (leftViewSize.width + hSpacing + hStackConfig.layoutMargins.totalWidth + vStackConfig.layoutMargins.totalWidth)) let topLabelSize = CVText.measureLabel(config: topLabelConfig, maxWidth: maxLabelWidth) let bottomLabelSize = CVText.measureLabel(config: bottomLabelConfig, maxWidth: maxLabelWidth) var vSubviewInfos = [ManualStackSubviewInfo]() vSubviewInfos.append(topLabelSize.asManualSubviewInfo()) vSubviewInfos.append(bottomLabelSize.asManualSubviewInfo()) let vStackMeasurement = ManualStackView.measure(config: vStackConfig, measurementBuilder: measurementBuilder, measurementKey: Self.measurementKey_vStack, subviewInfos: vSubviewInfos) var hSubviewInfos = [ManualStackSubviewInfo]() hSubviewInfos.append(leftViewSize.asManualSubviewInfo(hasFixedSize: true)) hSubviewInfos.append(vStackMeasurement.measuredSize.asManualSubviewInfo) let hStackMeasurement = ManualStackView.measure(config: hStackConfig, measurementBuilder: measurementBuilder, measurementKey: Self.measurementKey_hStack, subviewInfos: hSubviewInfos, maxWidth: maxWidth) return hStackMeasurement.measuredSize } // MARK: - Events public override func handleTap(sender: UIGestureRecognizer, componentDelegate: CVComponentDelegate, componentView: CVComponentView, renderItem: CVRenderItem) -> Bool { switch genericAttachment.attachment { case .stream: switch componentDelegate.didTapGenericAttachment(self) { case .handledByDelegate: break case .default: showShareUI(from: componentView.rootView) } case .pointer(_, let transitTierDownloadState): switch transitTierDownloadState { case .failed, .none: guard let message = renderItem.interaction as? TSMessage else { owsFailDebug("Invalid interaction.") return true } componentDelegate.didTapFailedOrPendingDownloads(message) case .enqueued, .downloading: break } } return true } private var qlPreviewTmpFileUrl: URL? { didSet { if let oldValue { try? OWSFileSystem.deleteFile(url: oldValue) } } } public func createQLPreviewController() -> QLPreviewController? { guard #available(iOS 14.8, *) else { return nil } switch attachmentStream?.concreteStreamType { case nil: return nil case .legacy(let tsAttachmentStream): guard let url = tsAttachmentStream.originalMediaURL, QLPreviewController.canPreview(url as NSURL) else { return nil } case .v2(let attachmentStream): guard let url = try? attachmentStream.makeDecryptedCopy() else { return nil } guard QLPreviewController.canPreview(url as NSURL) else { try? OWSFileSystem.deleteFile(url: url) return nil } self.qlPreviewTmpFileUrl = url } let previewController = QLPreviewController() previewController.dataSource = self previewController.delegate = self return previewController } /// Returns the `PKPass` represented by this attachment, if any. public func representedPKPass() -> PKPass? { guard attachmentStream?.mimeType == "application/vnd.apple.pkpass" else { return nil } guard let data = try? attachmentStream?.decryptedRawData() else { return nil } return try? PKPass(data: data) } public func showShareUI(from view: UIView) { guard let attachmentStream = try? attachmentStream?.asShareableResource() else { owsFailDebug("should not show the share UI unless there's a downloaded attachment") return } // TODO: Ensure share UI is shown from correct location. AttachmentSharing.showShareUI(for: attachmentStream, sender: view) } // MARK: - // Used for rendering some portion of an Conversation View item. // It could be the entire item or some part thereof. public class CVComponentViewGenericAttachment: NSObject, CVComponentView { fileprivate let hStackView = ManualStackView(name: "GenericAttachment.hStackView") fileprivate let vStackView = ManualStackView(name: "GenericAttachment.vStackView") fileprivate let topLabel = CVLabel() fileprivate let bottomLabel = CVLabel() fileprivate let fileTypeLabel = CVLabel() fileprivate let iconImageView = CVImageView() public var isDedicatedCellView = false public var rootView: UIView { hStackView } public func setIsCellVisible(_ isCellVisible: Bool) {} public func reset() { hStackView.reset() vStackView.reset() topLabel.text = nil bottomLabel.text = nil fileTypeLabel.text = nil iconImageView.image = nil } } } extension CVComponentGenericAttachment: QLPreviewControllerDataSource, QLPreviewControllerDelegate { public func numberOfPreviewItems(in controller: QLPreviewController) -> Int { return 1 } public func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { owsAssertDebug(index == 0) let url: URL? = { switch attachmentStream?.concreteStreamType { case .legacy(let tsAttachmentStream): return tsAttachmentStream.originalMediaURL case .v2: return qlPreviewTmpFileUrl case nil: return nil } }() return url.map { $0 as NSURL } ?? UnavailableItem() } public func previewControllerDidDismiss(_ controller: QLPreviewController) { self.qlPreviewTmpFileUrl = nil } private class UnavailableItem: NSObject, QLPreviewItem { var previewItemURL: URL? { nil } } } // MARK: - extension CVComponentGenericAttachment: CVAccessibilityComponent { public var accessibilityDescription: String { // TODO: We could include information about the attachment format, // and/or filename, and download state. OWSLocalizedString("ACCESSIBILITY_LABEL_ATTACHMENT", comment: "Accessibility label for attachment.") } }