TM-SGNL-iOS/Signal/ConversationView/CellViews/QuotedMessageView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

929 lines
43 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
public 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: SSKEnvironment.shared.contactManagerRef.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: SSKEnvironment.shared.contactManagerRef.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.attachmentContentType {
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.attachmentContentType {
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.asStream() == 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()
}
}