1015 lines
42 KiB
Swift
1015 lines
42 KiB
Swift
//
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
public import SignalUI
|
|
|
|
public protocol CVQuotedMessageViewDelegate: AnyObject {
|
|
|
|
func didTapDownloadQuotedReplyAttachment(_ quotedReply: QuotedReplyModel)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public class CVQuotedMessageView: ManualStackViewWithLayer {
|
|
|
|
public struct State: Equatable {
|
|
let quotedReplyModel: QuotedReplyModel
|
|
let displayableQuotedText: DisplayableText?
|
|
let conversationStyle: ConversationStyle
|
|
let isOutgoing: Bool
|
|
let quotedAuthorName: String
|
|
let memberLabel: 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: CVQuotedMessageViewDelegate?
|
|
|
|
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 var quotedAuthorLabel = UILabel()
|
|
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 tintView = ManualLayoutViewWithLayer(name: "tintView")
|
|
|
|
static func stateForConversation(
|
|
quotedReplyModel: QuotedReplyModel,
|
|
displayableQuotedText: DisplayableText?,
|
|
conversationStyle: ConversationStyle,
|
|
isOutgoing: Bool,
|
|
transaction: DBReadTransaction,
|
|
) -> State {
|
|
return State(
|
|
quotedReplyModel: quotedReplyModel,
|
|
displayableQuotedText: displayableQuotedText,
|
|
conversationStyle: conversationStyle,
|
|
isOutgoing: isOutgoing,
|
|
quotedAuthorName: SSKEnvironment.shared.contactManagerRef.displayName(
|
|
for: quotedReplyModel.originalMessageAuthorAddress,
|
|
tx: transaction,
|
|
).resolvedValue(),
|
|
memberLabel: quotedReplyModel.originalMessageMemberLabel,
|
|
)
|
|
}
|
|
|
|
// 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 }
|
|
fileprivate var quotedAuthorName: NSAttributedString {
|
|
var authorName: String
|
|
if quotedReplyModel.originalMessageAuthorAddress.isLocalAddress {
|
|
authorName = CommonStrings.you
|
|
} else {
|
|
authorName = state.quotedAuthorName
|
|
}
|
|
let padding = " "
|
|
if let labelString = state.memberLabel {
|
|
return NSAttributedString(string: authorName + padding + labelString)
|
|
} else {
|
|
return NSAttributedString(string: authorName)
|
|
}
|
|
}
|
|
|
|
let stripeThickness: CGFloat = 4
|
|
var stripeColor: UIColor {
|
|
switch (isIncoming, conversationStyle.hasWallpaper) {
|
|
case (true, true): .Signal.MaterialBase.fillPrimary
|
|
case (true, _): .Signal.LightBase.fillPrimary
|
|
case (false, _): .Signal.ColorBase.fillPrimary
|
|
}
|
|
}
|
|
|
|
var backgroundTint: UIColor {
|
|
switch (isIncoming, conversationStyle.hasWallpaper) {
|
|
case (true, true): .Signal.MaterialBase.fillTertiary
|
|
case (true, _): .Signal.LightBase.fillTertiary
|
|
case (false, _): .Signal.ColorBase.fillTertiary
|
|
}
|
|
}
|
|
|
|
private var textColor: UIColor {
|
|
isIncoming ? .Signal.label : .Signal.ColorBase.labelInverted
|
|
}
|
|
|
|
var quotedAuthorFont: UIFont { UIFont.dynamicTypeFootnote.semibold() }
|
|
var quotedAuthorColor: UIColor { textColor }
|
|
var quotedTextColor: UIColor { textColor }
|
|
var quotedTextFont: UIFont { UIFont.dynamicTypeSubheadline }
|
|
var fileTypeTextColor: UIColor { textColor }
|
|
var fileTypeFont: UIFont { quotedTextFont.italic() }
|
|
var filenameTextColor: UIColor { textColor }
|
|
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
|
|
|
|
var outerStackConfig: CVStackViewConfig {
|
|
CVStackViewConfig(
|
|
axis: .vertical,
|
|
alignment: .fill,
|
|
spacing: 8,
|
|
layoutMargins: UIEdgeInsets(hMargin: 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, .image, .video:
|
|
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, .image, .audio:
|
|
return false
|
|
case .video:
|
|
return true
|
|
case nil:
|
|
break
|
|
}
|
|
guard let mimeType = self.mimeType else {
|
|
return false
|
|
}
|
|
return MimeTypeUtil.isSupportedVideoMimeType(mimeType)
|
|
}
|
|
|
|
var quotedAuthorLabelConfig: CVLabelConfig {
|
|
let authorName = quotedAuthorName.string
|
|
|
|
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.nonPluralLocalizedStringWithFormat(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):
|
|
if state.quotedReplyModel.originalContent.isPoll {
|
|
let pollIcon = SignalSymbol.poll.attributedString(
|
|
dynamicTypeBaseSize: quotedTextFont.pointSize,
|
|
) + " "
|
|
let pollPrefix = OWSLocalizedString(
|
|
"POLL_LABEL",
|
|
comment: "Label specifying the message type as a poll",
|
|
) + ": "
|
|
|
|
labelText = .attributedText(pollIcon + NSAttributedString(string: pollPrefix + text))
|
|
} else {
|
|
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 nil:
|
|
if
|
|
case .attachmentStub(_, let stub) = quotedReplyModel.originalContent,
|
|
stub.renderingFlag == .voiceMessage
|
|
{
|
|
let iconPrefix = SignalSymbol.audioSquare.attributedString(
|
|
dynamicTypeBaseSize: quotedTextFont.pointSize,
|
|
)
|
|
let voiceMessageText = OWSLocalizedString(
|
|
"QUOTED_REPLY_TYPE_VOICE_MESSAGE",
|
|
comment: "Indicates this message is a quoted reply to a voice message.",
|
|
)
|
|
labelText = .attributedText(iconPrefix + " " + voiceMessageText)
|
|
} else 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: 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_STORY_FORMAT_THIRD_PERSON",
|
|
comment: "Label explaining that the content of a quoted message includes someone reacting to your story.",
|
|
)
|
|
} else {
|
|
let formatText = OWSLocalizedString(
|
|
"QUOTED_REPLY_REACTION_TO_STORY_FORMAT_SECOND_PERSON",
|
|
comment: "Label explaining that the content of a quoted message includes you reacting to someone's story. Embeds {{ %1$@ the story author }}.",
|
|
)
|
|
text = String.nonPluralLocalizedStringWithFormat(formatText, quotedAuthorName.string)
|
|
}
|
|
|
|
return CVLabelConfig.unstyledText(
|
|
text,
|
|
font: UIFont.dynamicTypeFootnote,
|
|
textColor: conversationStyle.bubbleSecondaryTextColor(isIncoming: isIncoming),
|
|
numberOfLines: 0,
|
|
)
|
|
}
|
|
|
|
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
|
|
tintView.backgroundColor = configurator.backgroundTint
|
|
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: CVQuotedMessageViewDelegate?,
|
|
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]()
|
|
|
|
stripeView.backgroundColor = configurator.stripeColor
|
|
hStackSubviews.append(stripeView)
|
|
|
|
var innerVStackSubviews = [UIView]()
|
|
|
|
if let memberLabel = state.memberLabel {
|
|
quotedAuthorLabel = CVCapsuleLabel(
|
|
attributedText: configurator.quotedAuthorName,
|
|
textColor: configurator.quotedTextColor,
|
|
font: configurator.quotedAuthorFont,
|
|
highlightRange: (configurator.quotedAuthorName.string as NSString).range(of: memberLabel, options: .backwards),
|
|
highlightFont: .dynamicTypeFootnote,
|
|
axLabelPrefix: nil,
|
|
presentationContext: configurator.isIncoming ? .messageBubbleQuoteReplyIncoming : .messageBubbleQuoteReplyOutgoing,
|
|
lineBreakMode: .byTruncatingTail,
|
|
numberOfLines: 0,
|
|
signalSymbolRange: nil,
|
|
onTap: nil,
|
|
)
|
|
} else {
|
|
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 {
|
|
// 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.asAnyPointer() != nil {
|
|
let wrapper = ManualLayoutViewWithLayer(name: "thumbnailDownloadFailedWrapper")
|
|
wrapper.backgroundColor = UIColor(rgbHex: 0xB5B5B5)
|
|
|
|
// TODO: design review icon and color
|
|
quotedImageView.setTemplateImage(Theme.iconImage(.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 {
|
|
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)
|
|
|
|
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 = "CVQuotedMessageView.measurementKey_outerStack"
|
|
private static let measurementKey_hStack = "CVQuotedMessageView.measurementKey_hStack"
|
|
private static let measurementKey_innerVStack = "CVQuotedMessageView.measurementKey_innerVStack"
|
|
private static let measurementKey_outerVStack = "CVQuotedMessageView.measurementKey_outerVStack"
|
|
private static let measurementKey_remotelySourcedContentStack = "CVQuotedMessageView.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: CGSize
|
|
if let memberLabel = state.memberLabel {
|
|
quotedAuthorSize = CVCapsuleLabel.measureLabel(
|
|
attributedText: configurator.quotedAuthorName,
|
|
font: configurator.quotedAuthorFont,
|
|
highlightRange: (configurator.quotedAuthorName.string as NSString).range(of: memberLabel, options: .backwards),
|
|
highlightFont: .dynamicTypeFootnote,
|
|
presentationContext: configurator.isIncoming ? .messageBubbleQuoteReplyIncoming : .messageBubbleQuoteReplyOutgoing,
|
|
maxWidth: maxLabelWidth,
|
|
signalSymbolRange: nil,
|
|
)
|
|
} else {
|
|
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))
|
|
}
|
|
|
|
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 didTapFailedThumbnailDownload(_ sender: UITapGestureRecognizer) {
|
|
Logger.debug("in didTapFailedThumbnailDownload")
|
|
|
|
guard let state = self.state else {
|
|
owsFailDebug("Missing state.")
|
|
return
|
|
}
|
|
let quotedReplyModel = state.quotedReplyModel
|
|
|
|
delegate?.didTapDownloadQuotedReplyAttachment(quotedReplyModel)
|
|
}
|
|
|
|
override public 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()
|
|
|
|
tintView.reset()
|
|
tintView.removeFromSuperview()
|
|
}
|
|
}
|