// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import SignalUI public protocol QuotedMessageViewDelegate: AnyObject { func didTapDownloadQuotedReplyAttachment(_ quotedReply: QuotedReplyModel) func didCancelQuotedReply() } // MARK: - public class QuotedMessageView: ManualStackViewWithLayer { public struct State: Equatable { let quotedReplyModel: QuotedReplyModel let displayableQuotedText: DisplayableText? let conversationStyle: ConversationStyle let isOutgoing: Bool let isForPreview: Bool let quotedAuthorName: String var quotedInteractionIdentifier: InteractionSnapshotIdentifier? { guard let timestamp = quotedReplyModel.originalMessageTimestamp else { return nil } return InteractionSnapshotIdentifier( timestamp: timestamp, authorAci: quotedReplyModel.originalMessageAuthorAddress.aci ) } } private var state: State? private weak var delegate: QuotedMessageViewDelegate? private let hStack = ManualStackView(name: "hStack") private let innerVStack = ManualStackView(name: "innerVStack") private let outerVStack = ManualStackView(name: "outerVStack") private let remotelySourcedContentStack = ManualStackViewWithLayer(name: "remotelySourcedContentStack") private let stripeView = UIView() private let quotedAuthorLabel = CVLabel() private let quotedTextLabel = CVLabel() private let quoteContentSourceLabel = CVLabel() private let quoteReactionHeaderLabel = CVLabel() private let quoteReactionLabel = CVLabel() private let quotedImageView = CVImageView() private let remotelySourcedContentIconView = CVImageView() // Background private let bubbleView = ManualLayoutViewWithLayer(name: "bubbleView") private let chatColorView = CVColorOrGradientView() private let tintView = ManualLayoutViewWithLayer(name: "tintView") static func stateForConversation( quotedReplyModel: QuotedReplyModel, displayableQuotedText: DisplayableText?, conversationStyle: ConversationStyle, isOutgoing: Bool, transaction: SDSAnyReadTransaction ) -> State { return State( quotedReplyModel: quotedReplyModel, displayableQuotedText: displayableQuotedText, conversationStyle: conversationStyle, isOutgoing: isOutgoing, isForPreview: false, quotedAuthorName: contactsManager.displayName( for: quotedReplyModel.originalMessageAuthorAddress, tx: transaction ).resolvedValue() ) } static func stateForPreview( quotedReplyModel: QuotedReplyModel, conversationStyle: ConversationStyle, transaction: SDSAnyReadTransaction ) -> State { var displayableQuotedText: DisplayableText? if let body = quotedReplyModel.originalMessageBody, !body.text.isEmpty { displayableQuotedText = DisplayableText.displayableText( withMessageBody: body, transaction: transaction ) } return State( quotedReplyModel: quotedReplyModel, displayableQuotedText: displayableQuotedText, conversationStyle: conversationStyle, isOutgoing: true, isForPreview: true, quotedAuthorName: contactsManager.displayName( for: quotedReplyModel.originalMessageAuthorAddress, tx: transaction ).resolvedValue() ) } // The Configurator can be used to: // // * Configure this view for rendering. // * Measure this view _without_ creating its views. private struct Configurator { let state: State var quotedReplyModel: QuotedReplyModel { state.quotedReplyModel } var displayableQuotedText: DisplayableText? { state.displayableQuotedText } var conversationStyle: ConversationStyle { state.conversationStyle } var isOutgoing: Bool { state.isOutgoing } var isIncoming: Bool { !isOutgoing } var isForPreview: Bool { state.isForPreview } var quotedAuthorName: String { state.quotedAuthorName } let stripeThickness: CGFloat = 4 var quotedAuthorFont: UIFont { UIFont.dynamicTypeSubheadlineClamped.semibold() } var quotedAuthorColor: UIColor { conversationStyle.quotedReplyAuthorColor() } var quotedTextColor: UIColor { conversationStyle.quotedReplyTextColor() } var quotedTextFont: UIFont { UIFont.dynamicTypeBody2 } var fileTypeTextColor: UIColor { conversationStyle.quotedReplyAttachmentColor() } var fileTypeFont: UIFont { quotedTextFont.italic() } var filenameTextColor: UIColor { conversationStyle.quotedReplyAttachmentColor() } var filenameFont: UIFont { quotedTextFont } var quotedAuthorHeight: CGFloat { quotedAuthorFont.lineHeight } let quotedAttachmentSizeWithoutQuotedText: CGFloat = 64 let quotedAttachmentSizeWithQuotedText: CGFloat = 72 var quotedAttachmentSize: CGSize { let height = hasQuotedText ? quotedAttachmentSizeWithQuotedText : quotedAttachmentSizeWithoutQuotedText if quotedReplyModel.originalContent.isStory { return CGSize(width: 0.625 * height, height: height) } else { return CGSize(square: height) } } var quotedReactionRect: CGRect { CGRect(x: 0, y: quotedAttachmentSize.height - 32, width: hasQuotedThumbnail ? 32 : 40, height: 32) } let remotelySourcedContentIconSize: CGFloat = 16 let cancelIconSize: CGFloat = 20 let cancelIconMargins = UIEdgeInsets(top: 6, leading: 6, bottom: 0, trailing: 6) var outerStackConfig: CVStackViewConfig { CVStackViewConfig(axis: .vertical, alignment: .fill, spacing: 8, layoutMargins: UIEdgeInsets(hMargin: isForPreview ? 0 : 8, vMargin: 0)) } var hStackConfig: CVStackViewConfig { CVStackViewConfig(axis: .horizontal, alignment: .fill, spacing: 8, layoutMargins: .zero) } var innerVStackConfig: CVStackViewConfig { CVStackViewConfig(axis: .vertical, alignment: .leading, spacing: 2, layoutMargins: UIEdgeInsets(hMargin: 0, vMargin: 6)) } var outerVStackConfig: CVStackViewConfig { CVStackViewConfig(axis: .vertical, alignment: .fill, spacing: 0, layoutMargins: .zero) } var remotelySourcedContentStackConfig: CVStackViewConfig { CVStackViewConfig(axis: .horizontal, alignment: .center, spacing: 3, layoutMargins: UIEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 4)) } var hasQuotedThumbnail: Bool { quotedReplyModel.hasQuotedThumbnail } var hasReaction: Bool { quotedReplyModel.storyReactionEmoji != nil } var mimeType: String? { guard let mimeType = quotedReplyModel.originalContent.attachmentMimeType, !mimeType.isEmpty else { return nil } return mimeType } var mimeTypeWithThumbnail: String? { guard let mimeType = self.mimeType else { return nil } guard mimeType != MimeType.textXSignalPlain.rawValue else { return nil } return mimeType } var isAudioAttachment: Bool { switch quotedReplyModel.originalContent.attachmentCachedContentType { case .file, .invalid, .image, .video, .animatedImage: return false case .audio: return true case nil: break } guard let mimeType = self.mimeType else { return false } return MimeTypeUtil.isSupportedAudioMimeType(mimeType) } var isVideoAttachment: Bool { switch quotedReplyModel.originalContent.attachmentCachedContentType { case .file, .invalid, .image, .audio, .animatedImage: return false case .video: return true case nil: break } guard let mimeType = self.mimeType else { return false } return MimeTypeUtil.isSupportedVideoMimeType(mimeType) } var highlightColor: UIColor { conversationStyle.quotedReplyHighlightColor() } var quotedAuthorLabelConfig: CVLabelConfig { let authorName: String if quotedReplyModel.originalMessageAuthorAddress.isLocalAddress { authorName = CommonStrings.you } else { authorName = quotedAuthorName } let text: String if quotedReplyModel.originalContent.isStory { let format = OWSLocalizedString("QUOTED_REPLY_STORY_AUTHOR_INDICATOR_FORMAT", comment: "Message header when you are quoting a story. Embeds {{ story author name }}") text = String(format: format, authorName) } else { text = authorName } return CVLabelConfig.unstyledText( text, font: quotedAuthorFont, textColor: quotedAuthorColor, numberOfLines: 1, lineBreakMode: .byTruncatingTail ) } var hasQuotedText: Bool { if let displayableQuotedText = self.displayableQuotedText, !displayableQuotedText.displayTextValue.isEmpty { return true } else { return false } } var quotedTextLabelConfig: CVLabelConfig { let labelText: CVTextValue var textAlignment: NSTextAlignment? let displayTextValue = self.displayableQuotedText?.displayTextValue.nilIfEmpty switch displayTextValue { case .text(let text): labelText = .text(text) textAlignment = text.naturalTextAlignment case .attributedText(let attributedText): let mutableText = NSMutableAttributedString(attributedString: attributedText) mutableText.addAttributesToEntireString([ .font: quotedTextFont, .foregroundColor: quotedTextColor ]) labelText = .attributedText(mutableText) textAlignment = attributedText.string.naturalTextAlignment case .messageBody(let messageBody): labelText = .messageBody(messageBody) textAlignment = messageBody.naturalTextAlignment case .none: if let fileTypeForSnippet = self.fileTypeForSnippet { labelText = .attributedText(NSAttributedString( string: fileTypeForSnippet, attributes: [ .font: fileTypeFont, .foregroundColor: fileTypeTextColor ] )) } else if let sourceFilename = quotedReplyModel.originalAttachmentSourceFilename?.filterStringForDisplay() { labelText = .attributedText(NSAttributedString( string: sourceFilename, attributes: [ .font: filenameFont, .foregroundColor: filenameTextColor ] )) } else if self.quotedReplyModel.originalContent.isGiftBadge { labelText = .attributedText(NSAttributedString( string: OWSLocalizedString( "DONATION_ON_BEHALF_OF_A_FRIEND_REPLY", comment: "Shown when you're replying to a donation message." ), // This appears in the same context as fileType, so use the same font/color. attributes: [.font: self.fileTypeFont, .foregroundColor: self.fileTypeTextColor] )) } else { let string = OWSLocalizedString("QUOTED_REPLY_TYPE_ATTACHMENT", comment: "Indicates this message is a quoted reply to an attachment of unknown type.") labelText = .attributedText(NSAttributedString( string: string, attributes: [ .font: fileTypeFont, .foregroundColor: fileTypeTextColor ] )) } } let displayConfig = HydratedMessageBody.DisplayConfiguration.quotedReply( font: quotedTextFont, textColor: .fixed(quotedTextColor) ) return CVLabelConfig( text: labelText, displayConfig: displayConfig, font: quotedTextFont, textColor: quotedTextColor, numberOfLines: isForPreview || hasQuotedThumbnail ? 1 : 2, lineBreakMode: .byTruncatingTail, textAlignment: textAlignment ) } var quoteContentSourceLabelConfig: CVLabelConfig { let text = OWSLocalizedString("QUOTED_REPLY_CONTENT_FROM_REMOTE_SOURCE", comment: "Footer label that appears below quoted messages when the quoted content was not derived locally. When the local user doesn't have a copy of the message being quoted, e.g. if it had since been deleted, we instead show the content specified by the sender.") return CVLabelConfig.unstyledText( text, font: UIFont.dynamicTypeFootnote, textColor: Theme.lightThemePrimaryColor, numberOfLines: 0, lineBreakMode: .byWordWrapping ) } var quoteReactionHeaderLabelConfig: CVLabelConfig { let text: String if quotedReplyModel.originalMessageAuthorAddress.isLocalAddress { text = OWSLocalizedString("QUOTED_REPLY_REACTION_TO_OWN_STORY", comment: "Header label that appears above quoted messages when the quoted content was includes a reaction to your own story.") } else { let formatText = OWSLocalizedString("QUOTED_REPLY_REACTION_TO_STORY_FORMAT", comment: "Header label that appears above quoted messages when the quoted content was includes a reaction to a story. Embeds {{ story author name }}") text = String(format: formatText, quotedAuthorName) } return CVLabelConfig.unstyledText( text, font: UIFont.dynamicTypeFootnote, textColor: conversationStyle.bubbleSecondaryTextColor(isIncoming: isIncoming) ) } var quoteReactionLabelConfig: CVLabelConfig { let font = UIFont.systemFont(ofSize: 28) return CVLabelConfig( text: .attributedText((quotedReplyModel.storyReactionEmoji ?? "").styled(with: .lineHeightMultiple(0.6))), displayConfig: .forUnstyledText(font: font, textColor: quotedTextColor), font: font, textColor: quotedTextColor ) } var fileTypeForSnippet: String? { // TODO: Are we going to use the filename? For all mimetypes? guard let mimeType = self.mimeType else { return nil } if MimeTypeUtil.isSupportedAudioMimeType(mimeType) { return OWSLocalizedString("QUOTED_REPLY_TYPE_AUDIO", comment: "Indicates this message is a quoted reply to an audio file.") } else if MimeTypeUtil.isSupportedVideoMimeType(mimeType) { return OWSLocalizedString("QUOTED_REPLY_TYPE_VIDEO", comment: "Indicates this message is a quoted reply to a video file.") } else if MimeTypeUtil.isSupportedDefinitelyAnimatedMimeType(mimeType) { if mimeType.caseInsensitiveCompare(MimeType.imageGif.rawValue) == .orderedSame { return OWSLocalizedString("QUOTED_REPLY_TYPE_GIF", comment: "Indicates this message is a quoted reply to animated GIF file.") } else { return OWSLocalizedString("QUOTED_REPLY_TYPE_IMAGE", comment: "Indicates this message is a quoted reply to an image file.") } } else if MimeTypeUtil.isSupportedImageMimeType(mimeType) { return OWSLocalizedString("QUOTED_REPLY_TYPE_PHOTO", comment: "Indicates this message is a quoted reply to a photo file.") } return nil } } private static let sharpCornerRadius: CGFloat = 4 private static let wideCornerRadius: CGFloat = 10 private func createBubbleView(sharpCorners: OWSDirectionalRectCorner, conversationStyle: ConversationStyle, configurator: Configurator, componentDelegate: CVComponentDelegate) -> ManualLayoutView { // Background chatColorView.configure(value: conversationStyle.bubbleChatColorOutgoing, referenceView: componentDelegate.view) bubbleView.addSubviewToFillSuperviewEdges(chatColorView) tintView.backgroundColor = (conversationStyle.isDarkThemeEnabled ? UIColor(white: 0, alpha: 0.4) : UIColor(white: 1, alpha: 0.6)) bubbleView.addSubviewToFillSuperviewMargins(tintView) // For incoming messages, manipulate leading margin // to render stripe. bubbleView.layoutMargins = UIEdgeInsets(top: 0, leading: (configurator.isIncoming ? configurator.stripeThickness : 0), bottom: 0, trailing: 0) // Mask & Rounding if sharpCorners.isEmpty || sharpCorners.contains(.allCorners) { bubbleView.layer.maskedCorners = .all bubbleView.layer.cornerRadius = sharpCorners.isEmpty ? Self.wideCornerRadius : Self.sharpCornerRadius } else { // Slow path. CA isn't optimized to handle corners of multiple radii // Let's do it by hand with a CAShapeLayer let maskLayer = CAShapeLayer() bubbleView.addLayoutBlock { view in let sharpCorners = UIView.uiRectCorner(forOWSDirectionalRectCorner: sharpCorners) let bezierPath = UIBezierPath.roundedRect(view.bounds, sharpCorners: sharpCorners, sharpCornerRadius: Self.sharpCornerRadius, wideCornerRadius: Self.wideCornerRadius) maskLayer.path = bezierPath.cgPath } bubbleView.layer.mask = maskLayer } return bubbleView } public func configureForRendering( state: State, delegate: QuotedMessageViewDelegate?, componentDelegate: CVComponentDelegate, sharpCorners: OWSDirectionalRectCorner, cellMeasurement: CVCellMeasurement ) { self.state = state self.delegate = delegate let configurator = Configurator(state: state) let conversationStyle = configurator.conversationStyle let quotedReplyModel = configurator.quotedReplyModel var hStackSubviews = [UIView]() if configurator.isForPreview || configurator.isOutgoing { stripeView.backgroundColor = .ows_white } else { // We render the stripe by manipulating the chat color overlay. stripeView.backgroundColor = .clear } hStackSubviews.append(stripeView) var innerVStackSubviews = [UIView]() let quotedAuthorLabelConfig = configurator.quotedAuthorLabelConfig quotedAuthorLabelConfig.applyForRendering(label: quotedAuthorLabel) innerVStackSubviews.append(quotedAuthorLabel) let quotedTextLabelConfig = configurator.quotedTextLabelConfig quotedTextLabelConfig.applyForRendering(label: quotedTextLabel) quotedTextSpoilerConfigBuilder.text = quotedTextLabelConfig.text quotedTextSpoilerConfigBuilder.displayConfig = quotedTextLabelConfig.displayConfig quotedTextSpoilerConfigBuilder.animationManager = componentDelegate.spoilerState.animationManager innerVStackSubviews.append(quotedTextLabel) innerVStack.configure(config: configurator.innerVStackConfig, cellMeasurement: cellMeasurement, measurementKey: Self.measurementKey_innerVStack, subviews: innerVStackSubviews) hStackSubviews.append(innerVStack) let thumbnailView: UIView? = { () -> UIView? in guard configurator.hasQuotedThumbnail else { return nil } let quotedImageView = self.quotedImageView // Use trilinear filters for better scaling quality at // some performance cost. quotedImageView.layer.minificationFilter = .trilinear quotedImageView.layer.magnificationFilter = .trilinear quotedImageView.layer.mask = nil switch configurator.quotedReplyModel.originalContent { case .textStory(let rendererFn): return rendererFn(componentDelegate.spoilerState) case .giftBadge: quotedImageView.image = UIImage(named: "gift-thumbnail") quotedImageView.contentMode = .scaleAspectFit quotedImageView.clipsToBounds = false let wrapper = ManualLayoutViewWithLayer(name: "giftBadgeWrapper") wrapper.addSubviewToFillSuperviewEdges(quotedImageView) // For outgoing replies to gift messages, the wrapping image is blue, and // the bubble can be the same shade of blue. This looks odd, so add a 1pt // white border in that case. if configurator.isOutgoing && !configurator.isForPreview { // The gift badge needs to know which corners to round, which depends on // whether or not there's adjacent content in the parent container. We care // about "edges that are against the rounded parent edges", and then we // round the corners at the intersection of those edges. For example, in // the common case, we'll be pressing against the top, trailing, and bottom // edges, so we round the .topTrailing and .bottomTrailing corners. var eligibleCorners: OWSDirectionalRectCorner = [.topTrailing, .bottomTrailing] if quotedReplyModel.sourceOfOriginal == .remote { eligibleCorners.remove(.bottomTrailing) } let maskLayer = CAShapeLayer() quotedImageView.addLayoutBlock { view in let borderWidth: CGFloat = 1 assert(borderWidth <= Self.sharpCornerRadius) assert(borderWidth <= Self.wideCornerRadius) let maskRect = view.bounds.insetBy(dx: borderWidth, dy: borderWidth) maskLayer.path = UIBezierPath.roundedRect( maskRect, sharpCorners: UIView.uiRectCorner( forOWSDirectionalRectCorner: sharpCorners.intersection(eligibleCorners) ), sharpCornerRadius: Self.sharpCornerRadius - borderWidth, wideCorners: UIView.uiRectCorner( forOWSDirectionalRectCorner: eligibleCorners.subtracting(sharpCorners) ), wideCornerRadius: Self.wideCornerRadius - borderWidth ).cgPath } quotedImageView.layer.mask = maskLayer wrapper.backgroundColor = .ows_white } return wrapper case .attachment(_, let attachment, let thumbnailImage), .mediaStory(_, let attachment, let thumbnailImage): if let thumbnailImage { quotedImageView.image = thumbnailImage // We need to specify a contentMode since the size of the image // might not match the aspect ratio of the view. quotedImageView.contentMode = .scaleAspectFill quotedImageView.clipsToBounds = true let wrapper = ManualLayoutView(name: "thumbnailImageWrapper") wrapper.addSubviewToFillSuperviewEdges(quotedImageView) if configurator.isVideoAttachment { let overlayView = ManualLayoutViewWithLayer(name: "video_overlay") overlayView.backgroundColor = .ows_black.withAlphaComponent(0.20) wrapper.addSubviewToFillSuperviewEdges(overlayView) let contentImageView = CVImageView() contentImageView.setTemplateImageName("play-fill", tintColor: .ows_white) contentImageView.setShadow(radius: 6, opacity: 0.24, offset: .zero, color: .ows_black) wrapper.addSubviewToCenterOnSuperviewWithDesiredSize(contentImageView) } return wrapper } else if attachment.attachment.asResourceStream() == nil, attachment.attachment.asTransitTierPointer() != nil { let wrapper = ManualLayoutViewWithLayer(name: "thumbnailDownloadFailedWrapper") wrapper.backgroundColor = configurator.highlightColor // TODO: design review icon and color quotedImageView.setTemplateImageName("refresh", tintColor: .white) quotedImageView.contentMode = .scaleAspectFit quotedImageView.clipsToBounds = false let iconSize = CGSize.square(configurator.quotedAttachmentSize.width * 0.5) wrapper.addSubviewToCenterOnSuperview(quotedImageView, size: iconSize) wrapper.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapFailedThumbnailDownload))) wrapper.isUserInteractionEnabled = true return wrapper } else { fallthrough } default: // TODO: Should we overlay the file extension like we do with CVComponentGenericAttachment quotedImageView.setTemplateImageName("generic-attachment", tintColor: .clear) quotedImageView.contentMode = .scaleAspectFit quotedImageView.clipsToBounds = false quotedImageView.tintColor = nil let wrapper = ManualLayoutView(name: "genericAttachmentWrapper") let iconSize = CGSize.square(configurator.quotedAttachmentSize.width * 0.5) wrapper.addSubviewToCenterOnSuperview(quotedImageView, size: iconSize) return wrapper } }() let trailingView: UIView if let thumbnailView = thumbnailView { if configurator.hasReaction { let wrapper = ManualLayoutView(name: "thumbnailWithReactionWrapper") wrapper.addSubview(thumbnailView) { _ in thumbnailView.frame = CGRect(origin: CGPoint(x: 16, y: 0), size: configurator.quotedAttachmentSize) } let reactionLabelConfig = configurator.quoteReactionLabelConfig reactionLabelConfig.applyForRendering(label: quoteReactionLabel) quoteReactionLabel.frame = configurator.quotedReactionRect wrapper.addSubview(quoteReactionLabel) trailingView = wrapper } else { trailingView = thumbnailView } } else if configurator.hasReaction { let wrapper = ManualLayoutView(name: "reactionWrapper") let reactionLabelConfig = configurator.quoteReactionLabelConfig reactionLabelConfig.applyForRendering(label: quoteReactionLabel) quoteReactionLabel.frame = configurator.quotedReactionRect wrapper.addSubview(quoteReactionLabel) trailingView = wrapper } else { // If there's no attachment, add an empty view so that // the stack view's spacing serves as a margin between // the text views and the trailing edge. trailingView = UIView.transparentSpacer() } hStackSubviews.append(trailingView) if configurator.isForPreview { let cancelButton = UIButton(type: .custom) cancelButton.setImage(UIImage(imageLiteralResourceName: "x-20"), for: .normal) cancelButton.imageView?.tintColor = Theme.secondaryTextAndIconColor cancelButton.addTarget(self, action: #selector(didTapCancel), for: .touchUpInside) let cancelWrapper = ManualLayoutView(name: "cancelWrapper") cancelWrapper.layoutMargins = configurator.cancelIconMargins cancelWrapper.addSubviewToFillSuperviewMargins(cancelButton) hStackSubviews.append(cancelWrapper) } hStack.configure(config: configurator.hStackConfig, cellMeasurement: cellMeasurement, measurementKey: Self.measurementKey_hStack, subviews: hStackSubviews) var outerVStackSubviews = [UIView]() outerVStackSubviews.append(hStack) if quotedReplyModel.sourceOfOriginal == .remote { remotelySourcedContentIconView.setTemplateImageName("link-slash-compact", tintColor: Theme.lightThemePrimaryColor) let quoteContentSourceLabelConfig = configurator.quoteContentSourceLabelConfig quoteContentSourceLabelConfig.applyForRendering(label: quoteContentSourceLabel) remotelySourcedContentStack.configure(config: configurator.remotelySourcedContentStackConfig, cellMeasurement: cellMeasurement, measurementKey: Self.measurementKey_remotelySourcedContentStack, subviews: [ remotelySourcedContentIconView, quoteContentSourceLabel ]) remotelySourcedContentStack.backgroundColor = UIColor.white.withAlphaComponent(0.4) outerVStackSubviews.append(remotelySourcedContentStack) } outerVStack.configure(config: configurator.outerVStackConfig, cellMeasurement: cellMeasurement, measurementKey: Self.measurementKey_outerVStack, subviews: outerVStackSubviews) var outerStackViews = [UIView]() if configurator.hasReaction { let reactionLabelConfig = configurator.quoteReactionHeaderLabelConfig reactionLabelConfig.applyForRendering(label: quoteReactionHeaderLabel) outerStackViews.append(quoteReactionHeaderLabel) } let bubbleView = createBubbleView(sharpCorners: sharpCorners, conversationStyle: conversationStyle, configurator: configurator, componentDelegate: componentDelegate) bubbleView.addSubviewToFillSuperviewEdges(outerVStack) bubbleView.clipsToBounds = true outerStackViews.append(bubbleView) self.configure(config: configurator.outerStackConfig, cellMeasurement: cellMeasurement, measurementKey: Self.measurementKey_outerStack, subviews: outerStackViews) } public func setIsCellVisible(_ isCellVisible: Bool) { quotedTextSpoilerConfigBuilder.isViewVisible = isCellVisible } // MARK: - Measurement private static let measurementKey_outerStack = "QuotedMessageView.measurementKey_outerStack" private static let measurementKey_hStack = "QuotedMessageView.measurementKey_hStack" private static let measurementKey_innerVStack = "QuotedMessageView.measurementKey_innerVStack" private static let measurementKey_outerVStack = "QuotedMessageView.measurementKey_outerVStack" private static let measurementKey_remotelySourcedContentStack = "QuotedMessageView.measurementKey_remotelySourcedContentStack" public static func measure(state: State, maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize { let configurator = Configurator(state: state) let outerStackConfig = configurator.outerStackConfig let hStackConfig = configurator.hStackConfig let innerVStackConfig = configurator.innerVStackConfig let outerVStackConfig = configurator.outerVStackConfig let hasQuotedThumbnail = configurator.hasQuotedThumbnail let hasReaction = configurator.hasReaction let quotedAttachmentSize = configurator.quotedAttachmentSize let quotedReactionRect = configurator.quotedReactionRect let quotedReplyModel = configurator.quotedReplyModel var maxLabelWidth = (maxWidth - (configurator.stripeThickness + hStackConfig.spacing * 2 + hStackConfig.layoutMargins.totalWidth + innerVStackConfig.layoutMargins.totalWidth + outerVStackConfig.layoutMargins.totalWidth + outerStackConfig.layoutMargins.totalWidth)) if hasQuotedThumbnail { maxLabelWidth -= quotedAttachmentSize.width if hasReaction { maxLabelWidth -= quotedReactionRect.width / 2 } } else if hasReaction { maxLabelWidth -= quotedReactionRect.width } maxLabelWidth = max(0, maxLabelWidth) var innerVStackSubviewInfos = [ManualStackSubviewInfo]() let quotedAuthorLabelConfig = configurator.quotedAuthorLabelConfig let quotedAuthorSize = CVText.measureLabel(config: quotedAuthorLabelConfig, maxWidth: maxLabelWidth) innerVStackSubviewInfos.append(quotedAuthorSize.asManualSubviewInfo) let quotedTextLabelConfig = configurator.quotedTextLabelConfig let quotedTextSize = CVText.measureLabel(config: quotedTextLabelConfig, maxWidth: maxLabelWidth) innerVStackSubviewInfos.append(quotedTextSize.asManualSubviewInfo) let innerVStackMeasurement = ManualStackView.measure(config: innerVStackConfig, measurementBuilder: measurementBuilder, measurementKey: Self.measurementKey_innerVStack, subviewInfos: innerVStackSubviewInfos) var hStackSubviewInfos = [ManualStackSubviewInfo]() let stripeSize = CGSize(width: configurator.stripeThickness, height: 0) hStackSubviewInfos.append(stripeSize.asManualSubviewInfo(hasFixedWidth: true)) hStackSubviewInfos.append(innerVStackMeasurement.measuredSize.asManualSubviewInfo) if hasQuotedThumbnail { if hasReaction { let attachmentPlusReactionSize = quotedAttachmentSize + CGSize(width: quotedReactionRect.width / 2, height: 0) hStackSubviewInfos.append(attachmentPlusReactionSize.asManualSubviewInfo(hasFixedWidth: true)) } else { hStackSubviewInfos.append(quotedAttachmentSize.asManualSubviewInfo(hasFixedWidth: true)) } } else if hasReaction { hStackSubviewInfos.append(CGSize(width: quotedReactionRect.width, height: quotedAttachmentSize.height).asManualSubviewInfo(hasFixedWidth: true)) } else { hStackSubviewInfos.append(CGSize.zero.asManualSubviewInfo(hasFixedWidth: true)) } if configurator.isForPreview { let cancelIconSize = CGSize.square(configurator.cancelIconSize) let cancelWrapperSize = cancelIconSize + configurator.cancelIconMargins.asSize hStackSubviewInfos.append(cancelWrapperSize.asManualSubviewInfo(hasFixedWidth: true)) } let hStackMeasurement = ManualStackView.measure(config: hStackConfig, measurementBuilder: measurementBuilder, measurementKey: Self.measurementKey_hStack, subviewInfos: hStackSubviewInfos) var outerVStackSubviewInfos = [ManualStackSubviewInfo]() outerVStackSubviewInfos.append(hStackMeasurement.measuredSize.asManualSubviewInfo) if quotedReplyModel.sourceOfOriginal == .remote { let remotelySourcedContentIconSize = CGSize.square(configurator.remotelySourcedContentIconSize) let quoteContentSourceLabelConfig = configurator.quoteContentSourceLabelConfig let quoteContentSourceSize = CVText.measureLabel(config: quoteContentSourceLabelConfig, maxWidth: maxLabelWidth) let innerVStackMeasurement = ManualStackView.measure(config: configurator.remotelySourcedContentStackConfig, measurementBuilder: measurementBuilder, measurementKey: Self.measurementKey_remotelySourcedContentStack, subviewInfos: [ remotelySourcedContentIconSize.asManualSubviewInfo(hasFixedSize: true), quoteContentSourceSize.asManualSubviewInfo ]) outerVStackSubviewInfos.append(innerVStackMeasurement.measuredSize.asManualSubviewInfo) } let outerVStackMeasurement = ManualStackView.measure(config: outerVStackConfig, measurementBuilder: measurementBuilder, measurementKey: Self.measurementKey_outerVStack, subviewInfos: outerVStackSubviewInfos) var outerStackSubviewInfos = [ManualStackSubviewInfo]() if hasReaction { let reactionLabelConfig = configurator.quoteReactionHeaderLabelConfig let reactionLabelSize = CVText.measureLabel(config: reactionLabelConfig, maxWidth: maxLabelWidth) outerStackSubviewInfos.append(reactionLabelSize.asManualSubviewInfo) } outerStackSubviewInfos.append(outerVStackMeasurement.measuredSize.asManualSubviewInfo) let outerStackMeasurement = ManualStackView.measure(config: outerStackConfig, measurementBuilder: measurementBuilder, measurementKey: Self.measurementKey_outerStack, subviewInfos: outerStackSubviewInfos, maxWidth: maxWidth) return outerStackMeasurement.measuredSize } // MARK: - Spoiler Animations private lazy var quotedTextSpoilerConfigBuilder = SpoilerableTextConfig.Builder(isViewVisible: false) { didSet { quotedTextLabelSpoilerAnimator.updateAnimationState(quotedTextSpoilerConfigBuilder) } } private lazy var quotedTextLabelSpoilerAnimator: SpoilerableLabelAnimator = { let animator = SpoilerableLabelAnimator(label: quotedTextLabel) animator.updateAnimationState(quotedTextSpoilerConfigBuilder) return animator }() // MARK: - @objc private func didTapCancel() { delegate?.didCancelQuotedReply() } @objc private func didTapFailedThumbnailDownload(_ sender: UITapGestureRecognizer) { Logger.debug("in didTapFailedThumbnailDownload") guard let state = self.state else { owsFailDebug("Missing state.") return } let quotedReplyModel = state.quotedReplyModel delegate?.didTapDownloadQuotedReplyAttachment(quotedReplyModel) } public func updateAppearance() { chatColorView.updateAppearance() } public override func reset() { super.reset() self.state = nil self.delegate = nil hStack.reset() innerVStack.reset() outerVStack.reset() remotelySourcedContentStack.reset() quotedAuthorLabel.text = nil quotedTextLabel.text = nil quoteContentSourceLabel.text = nil quoteReactionHeaderLabel.text = nil quoteReactionLabel.text = nil quotedImageView.image = nil remotelySourcedContentIconView.image = nil bubbleView.reset() bubbleView.removeFromSuperview() chatColorView.reset() chatColorView.removeFromSuperview() tintView.reset() tintView.removeFromSuperview() } }