929 lines
43 KiB
Swift
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()
|
|
}
|
|
}
|