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

610 lines
24 KiB
Swift

//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
protocol QuotedReplyPreviewDelegate: AnyObject {
func quotedReplyPreviewDidPressCancel(_ preview: QuotedReplyPreview)
}
class QuotedReplyPreview: UIView, QuotedMessageSnippetViewDelegate {
public weak var delegate: QuotedReplyPreviewDelegate?
private let quotedReplyDraft: DraftQuotedReplyModel
private let conversationStyle: ConversationStyle
private let spoilerState: SpoilerRenderState
private var quotedMessageView: QuotedMessageSnippetView?
private var heightConstraint: NSLayoutConstraint!
@available(*, unavailable, message: "use other constructor instead.")
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@available(*, unavailable, message: "use other constructor instead.")
override init(frame: CGRect) {
fatalError("init(frame:) has not been implemented")
}
init(
quotedReplyDraft: DraftQuotedReplyModel,
conversationStyle: ConversationStyle,
spoilerState: SpoilerRenderState
) {
self.quotedReplyDraft = quotedReplyDraft
self.conversationStyle = conversationStyle
self.spoilerState = spoilerState
super.init(frame: .zero)
self.heightConstraint = self.autoSetDimension(.height, toSize: 0)
updateContents()
NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil)
}
private let draftMarginTop: CGFloat = 6
func updateContents() {
subviews.forEach { $0.removeFromSuperview() }
let hMargin: CGFloat = 6
self.layoutMargins = UIEdgeInsets(top: draftMarginTop,
left: hMargin,
bottom: 0,
right: hMargin)
// We instantiate quotedMessageView late to ensure that it is updated
// every time contentSizeCategoryDidChange (i.e. when dynamic type
// sizes changes).
let quotedMessageView = QuotedMessageSnippetView(
quotedMessage: quotedReplyDraft,
conversationStyle: conversationStyle,
spoilerState: spoilerState
)
quotedMessageView.delegate = self
self.quotedMessageView = quotedMessageView
quotedMessageView.setContentHuggingHorizontalLow()
quotedMessageView.setCompressionResistanceHorizontalLow()
quotedMessageView.backgroundColor = .clear
self.addSubview(quotedMessageView)
quotedMessageView.autoPinEdgesToSuperviewMargins()
updateHeight()
}
// MARK: Sizing
func updateHeight() {
guard let quotedMessageView else {
owsFailDebug("missing quotedMessageView")
return
}
let size = quotedMessageView.systemLayoutSizeFitting(.square(CGFloat.greatestFiniteMagnitude))
heightConstraint.constant = size.height + draftMarginTop
}
@objc
private func contentSizeCategoryDidChange(_ notification: Notification) {
Logger.debug("")
updateContents()
}
// MARK: QuotedMessageSnippetViewDelegate
fileprivate func didTapCancelInQuotedMessageSnippet(view: QuotedMessageSnippetView) {
delegate?.quotedReplyPreviewDidPressCancel(self)
}
}
private protocol QuotedMessageSnippetViewDelegate: AnyObject {
func didTapCancelInQuotedMessageSnippet(view: QuotedMessageSnippetView)
}
private class QuotedMessageSnippetView: UIView {
weak var delegate: QuotedMessageSnippetViewDelegate?
private let quotedMessage: DraftQuotedReplyModel
private let conversationStyle: ConversationStyle
private let spoilerState: SpoilerRenderState
private lazy var displayableQuotedText: DisplayableText? = {
QuotedMessageSnippetView.displayableTextWithSneakyTransaction(
forPreview: quotedMessage,
spoilerState: spoilerState
)
}()
init(
quotedMessage: DraftQuotedReplyModel,
conversationStyle: ConversationStyle,
spoilerState: SpoilerRenderState
) {
self.quotedMessage = quotedMessage
self.conversationStyle = conversationStyle
self.spoilerState = spoilerState
super.init(frame: .zero)
isUserInteractionEnabled = true
layoutMargins = .zero
clipsToBounds = true
createViewContents()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
if let quotedTextLabelSpoilerAnimator {
spoilerState.animationManager.removeViewAnimator(quotedTextLabelSpoilerAnimator)
}
}
// MARK: Layout
private lazy var quotedAuthorLabel: UILabel = {
let quotedAuthor: String
if quotedMessage.isOriginalMessageAuthorLocalUser {
quotedAuthor = CommonStrings.you
} else {
let authorName = SSKEnvironment.shared.databaseStorageRef.read { tx in
return SSKEnvironment.shared.contactManagerRef.displayName(
for: quotedMessage.originalMessageAuthorAddress,
tx: tx
).resolvedValue()
}
quotedAuthor = String(
format: NSLocalizedString(
"QUOTED_REPLY_AUTHOR_INDICATOR_FORMAT",
comment: "Indicates the author of a quoted message. Embeds {{the author's name or phone number}}."
),
authorName
)
}
let label = UILabel()
label.text = quotedAuthor
label.font = Layout.quotedAuthorFont
label.textColor = conversationStyle.quotedReplyAuthorColor()
label.lineBreakMode = .byTruncatingTail
label.numberOfLines = 1
label.setContentHuggingVerticalHigh()
label.setContentHuggingHorizontalLow()
label.setCompressionResistanceHorizontalLow()
return label
}()
private var quotedTextLabelSpoilerAnimator: SpoilerableLabelAnimator?
private lazy var quotedTextLabel: UILabel = {
let label = UILabel()
let attributedText: NSAttributedString
if let displayableQuotedText, !displayableQuotedText.displayTextValue.isEmpty {
let config = HydratedMessageBody.DisplayConfiguration.quotedReply(
font: Layout.quotedTextFont,
textColor: .fixed(conversationStyle.quotedReplyTextColor())
)
attributedText = styleDisplayableQuotedText(
displayableQuotedText,
config: config,
quotedReplyModel: quotedMessage,
spoilerState: spoilerState
)
let animator = SpoilerableLabelAnimator(label: label)
self.quotedTextLabelSpoilerAnimator = animator
var spoilerConfig = SpoilerableTextConfig.Builder(isViewVisible: true)
spoilerConfig.text = displayableQuotedText.displayTextValue
spoilerConfig.displayConfig = config
spoilerConfig.animationManager = self.spoilerState.animationManager
if let config = spoilerConfig.build() {
animator.updateAnimationState(config)
} else {
owsFailDebug("Unable to build spoiler animator")
}
} else if let fileTypeForSnippet {
attributedText = NSAttributedString(
string: fileTypeForSnippet,
attributes: [
.font: Layout.fileTypeFont,
.foregroundColor: conversationStyle.quotedReplyAttachmentColor()
]
)
} else if let sourceFilename = sourceFilenameForSnippet(quotedMessage.content)?.filterForDisplay {
attributedText = NSAttributedString(
string: sourceFilename,
attributes: [
.font: Layout.filenameFont,
.foregroundColor: conversationStyle.quotedReplyAttachmentColor()
]
)
} else if quotedMessage.content.isGiftBadge {
attributedText = NSAttributedString(
string: NSLocalizedString(
"DONATION_ON_BEHALF_OF_A_FRIEND_REPLY",
comment: "Shown when you're replying to a donation message."
),
attributes: [
.font: Layout.fileTypeFont,
.foregroundColor: conversationStyle.quotedReplyAttachmentColor()
]
)
} else {
attributedText = NSAttributedString(
string: NSLocalizedString(
"QUOTED_REPLY_TYPE_ATTACHMENT",
comment: "Indicates this message is a quoted reply to an attachment of unknown type."
),
attributes: [
.font: Layout.fileTypeFont,
.foregroundColor: conversationStyle.quotedReplyAttachmentColor()
]
)
}
label.numberOfLines = 1
label.lineBreakMode = .byTruncatingTail
label.textAlignment = displayableQuotedText?.displayTextNaturalAlignment ?? .natural
label.attributedText = attributedText
label.setContentHuggingHorizontalLow()
label.setCompressionResistanceHorizontalLow()
label.setCompressionResistanceVerticalHigh()
return label
}()
private lazy var quoteContentSourceLabel: UILabel = {
let label = UILabel()
label.font = .dynamicTypeFootnote
label.textColor = Theme.lightThemePrimaryColor
label.text = NSLocalizedString("QUOTED_REPLY_CONTENT_FROM_REMOTE_SOURCE", comment: "")
return label
}()
private func buildRemoteContentSourceView() -> UIView {
let glyphImageView = UIImageView(image: UIImage(imageLiteralResourceName: "link-slash-compact"))
glyphImageView.tintColor = Theme.lightThemePrimaryColor
glyphImageView.autoSetDimensions(to: .square(Layout.remotelySourcedContentGlyphLength))
let sourceRow = UIStackView(arrangedSubviews: [ glyphImageView, quoteContentSourceLabel ])
sourceRow.axis = .horizontal
sourceRow.alignment = .center
// TODO verify spacing w/ design
sourceRow.spacing = 3
sourceRow.isLayoutMarginsRelativeArrangement = true
let leftMargin: CGFloat = 8
let rowMargin: CGFloat = 4
sourceRow.layoutMargins = UIEdgeInsets(top: rowMargin, leading: leftMargin, bottom: rowMargin, trailing: rowMargin)
sourceRow.addBackgroundView(withBackgroundColor: .ows_whiteAlpha40)
return sourceRow
}
private func buildImageView(image: UIImage) -> UIImageView {
let imageView = UIImageView(image: image)
// We need to specify a contentMode since the size of the image
// might not match the aspect ratio of the view.
imageView.contentMode = .scaleAspectFill
// Use trilinear filters for better scaling quality at
// some performance cost.
imageView.layer.minificationFilter = .trilinear
imageView.layer.magnificationFilter = .trilinear
return imageView
}
private enum Layout {
static let hSpacing: CGFloat = 8
static var quotedAuthorFont: UIFont {
UIFont.dynamicTypeSubheadline.semibold()
}
static var quotedAuthorHeight: CGFloat {
ceil(quotedAuthorFont.lineHeight)
}
static var quotedTextFont: UIFont {
.dynamicTypeBody
}
static var filenameFont: UIFont {
quotedTextFont
}
static var fileTypeFont: UIFont {
quotedTextFont.italic()
}
static let quotedAttachmentSize: CGFloat = 54
static let remotelySourcedContentGlyphLength: CGFloat = 16
}
private func createViewContents() {
let maskLayer = CAShapeLayer()
let innerBubbleView = OWSLayerView(
frame: .zero,
layoutCallback: { layerView in
let bezierPath = UIBezierPath.roundedRect(
layerView.bounds,
sharpCorners: [ .bottomLeft, .bottomRight ],
sharpCornerRadius: 4,
wideCornerRadius: 12
)
maskLayer.path = bezierPath.cgPath
}
)
innerBubbleView.layer.mask = maskLayer
// Background
let chatColorView = CVColorOrGradientView.build(conversationStyle: conversationStyle, referenceView: self)
chatColorView.shouldDeactivateConstraints = false
innerBubbleView.addSubview(chatColorView)
chatColorView.autoPinEdgesToSuperviewEdges()
let tintView = UIView()
tintView.backgroundColor = conversationStyle.isDarkThemeEnabled ? .ows_blackAlpha40 : .ows_whiteAlpha60
innerBubbleView.addSubview(tintView)
tintView.autoPinEdgesToSuperviewEdges()
addSubview(innerBubbleView)
innerBubbleView.autoPinEdgesToSuperviewMargins()
let hStackView = UIStackView()
hStackView.axis = .horizontal
hStackView.spacing = Layout.hSpacing
let stripeView = UIView()
stripeView.backgroundColor = .white
stripeView.autoSetDimension(.width, toSize: 4)
hStackView.addArrangedSubview(stripeView)
let vStackView = UIStackView()
vStackView.axis = .vertical
vStackView.layoutMargins = UIEdgeInsets(hMargin: 0, vMargin: 7)
vStackView.isLayoutMarginsRelativeArrangement = true
vStackView.spacing = 2
hStackView.addArrangedSubview(vStackView)
vStackView.addArrangedSubview(quotedAuthorLabel)
quotedAuthorLabel.autoSetDimension(.height, toSize: Layout.quotedAuthorHeight)
vStackView.addArrangedSubview(quotedTextLabel)
self.createContentView(for: quotedMessage.content, in: hStackView)
let contentView: UIView
if quotedMessage.content.isRemotelySourced {
let quoteSourceWrapper = UIStackView(arrangedSubviews: [ hStackView, buildRemoteContentSourceView() ])
quoteSourceWrapper.axis = .vertical
contentView = quoteSourceWrapper
} else {
contentView = hStackView
}
let cancelButton = UIButton(type: .custom)
cancelButton.setImage(UIImage(imageLiteralResourceName: "x-20"), for: .normal)
cancelButton.tintColor = Theme.secondaryTextAndIconColor
cancelButton.addTarget(self, action: #selector(didTapCancel), for: .touchUpInside)
cancelButton.setContentHuggingHorizontalHigh()
cancelButton.setCompressionResistanceHorizontalHigh()
let cancelStack = UIStackView(arrangedSubviews: [cancelButton])
cancelStack.axis = .horizontal
cancelStack.alignment = .top
cancelStack.isLayoutMarginsRelativeArrangement = true
cancelStack.layoutMargins = UIEdgeInsets(top: 6, leading: 2, bottom: 0, trailing: 6)
let cancelWrapper = UIStackView(arrangedSubviews: [ contentView, cancelStack ])
cancelWrapper.axis = .horizontal
innerBubbleView.addSubview(cancelWrapper)
cancelWrapper.autoPinEdgesToSuperviewEdges()
}
private func createContentView(for content: DraftQuotedReplyModel.Content, in hStackView: UIStackView) {
switch content {
case let .attachment(_, _, attachment, thumbnailImage):
let quotedAttachmentView = self.createAttachmentView(attachment, thumbnailImage: thumbnailImage)
quotedAttachmentView.autoSetDimensions(to: .square(Layout.quotedAttachmentSize))
hStackView.addArrangedSubview(quotedAttachmentView)
case .attachmentStub:
let view = createStubAttachmentView()
view.autoSetDimensions(to: .square(Layout.quotedAttachmentSize))
hStackView.addArrangedSubview(view)
case let .edit(_, _, content):
return createContentView(for: content, in: hStackView)
case .giftBadge:
let contentImageView = buildImageView(image: UIImage(imageLiteralResourceName: "gift-thumbnail"))
contentImageView.contentMode = .scaleAspectFit
let wrapper = UIView.transparentContainer()
wrapper.addSubview(contentImageView)
contentImageView.autoCenterInSuperview()
contentImageView.autoSetDimension(.width, toSize: Layout.quotedAttachmentSize)
wrapper.autoSetDimensions(to: .square(Layout.quotedAttachmentSize))
hStackView.addArrangedSubview(wrapper)
case .payment, .text, .viewOnce, .contactShare, .storyReactionEmoji:
// 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.
let emptyView = UIView.transparentContainer()
emptyView.autoSetDimension(.width, toSize: 0)
hStackView.addArrangedSubview(emptyView)
}
}
private func createAttachmentView(_ attachment: Attachment, thumbnailImage: UIImage?) -> UIView {
let quotedAttachmentView: UIView
if let thumbnailImage {
let contentImageView = buildImageView(image: thumbnailImage)
contentImageView.clipsToBounds = true
// Mime type is spoofable by the sender but this view doesn't support playback anyway.
if MimeTypeUtil.isSupportedVideoMimeType(attachment.mimeType) {
let playIconImageView = buildImageView(image: UIImage(imageLiteralResourceName: "play-fill"))
playIconImageView.tintColor = .white
contentImageView.addSubview(playIconImageView)
playIconImageView.autoCenterInSuperview()
}
quotedAttachmentView = contentImageView
} else if attachment.asTransitTierPointer() != nil {
let contentImageView = buildImageView(image: UIImage(imageLiteralResourceName: "refresh"))
contentImageView.contentMode = .scaleAspectFit
contentImageView.tintColor = .white
contentImageView.autoSetDimensions(to: .square(Layout.quotedAttachmentSize * 0.5))
let containerView = UIView.container()
containerView.backgroundColor = conversationStyle.quotedReplyHighlightColor()
containerView.addSubview(contentImageView)
contentImageView.autoCenterInSuperview()
quotedAttachmentView = containerView
} else {
quotedAttachmentView = createStubAttachmentView()
}
return quotedAttachmentView
}
private func createStubAttachmentView() -> UIView {
// TODO: Should we overlay the file extension like we do with CVComponentGenericAttachment?
let contentImageView = buildImageView(image: UIImage(imageLiteralResourceName: "generic-attachment"))
contentImageView.autoSetDimension(.width, toSize: Layout.quotedAttachmentSize * 0.5)
contentImageView.contentMode = .scaleAspectFit
let wrapper = UIView.transparentContainer()
wrapper.addSubview(contentImageView)
contentImageView.autoCenterInSuperview()
return wrapper
}
@objc
private func didTapCancel() {
delegate?.didTapCancelInQuotedMessageSnippet(view: self)
}
// MARK: -
private func mimeTypeAndIsLooping(_ content: DraftQuotedReplyModel.Content) -> (String, Bool)? {
switch content {
case .attachmentStub(_, let stub) where stub.mimeType != nil:
return (stub.mimeType!, false)
case .attachment(_, let reference, let attachment, _):
return (attachment.mimeType, reference.renderingFlag == .shouldLoop)
case .edit(_, _, let innerContent):
return mimeTypeAndIsLooping(innerContent)
case .giftBadge, .text, .payment, .attachmentStub, .viewOnce, .contactShare, .storyReactionEmoji:
return nil
}
}
private var fileTypeForSnippet: String? {
guard let (mimeType, isLoopingVideo) = mimeTypeAndIsLooping(quotedMessage.content) else {
return nil
}
if MimeTypeUtil.isSupportedAudioMimeType(mimeType) {
return NSLocalizedString(
"QUOTED_REPLY_TYPE_AUDIO",
comment: "Indicates this message is a quoted reply to an audio file."
)
} else if MimeTypeUtil.isSupportedDefinitelyAnimatedMimeType(mimeType) {
if mimeType.caseInsensitiveCompare(MimeType.imageGif.rawValue) == .orderedSame {
return NSLocalizedString(
"QUOTED_REPLY_TYPE_GIF",
comment: "Indicates this message is a quoted reply to animated GIF file."
)
} else {
return NSLocalizedString(
"QUOTED_REPLY_TYPE_IMAGE",
comment: "Indicates this message is a quoted reply to an image file."
)
}
} else if isLoopingVideo && MimeTypeUtil.isSupportedVideoMimeType(mimeType) {
return NSLocalizedString(
"QUOTED_REPLY_TYPE_GIF",
comment: "Indicates this message is a quoted reply to animated GIF file."
)
} else if MimeTypeUtil.isSupportedVideoMimeType(mimeType) {
return NSLocalizedString(
"QUOTED_REPLY_TYPE_VIDEO",
comment: "Indicates this message is a quoted reply to a video file."
)
} else if MimeTypeUtil.isSupportedImageMimeType(mimeType) {
return NSLocalizedString(
"QUOTED_REPLY_TYPE_PHOTO",
comment: "Indicates this message is a quoted reply to a photo file."
)
}
return nil
}
private func sourceFilenameForSnippet(_ content: DraftQuotedReplyModel.Content) -> String? {
switch content {
case .attachmentStub(_, let stub):
return stub.sourceFilename
case .attachment(_, let reference, _, _):
return reference.sourceFilename
case .edit(_, _, let innerContent):
return sourceFilenameForSnippet(innerContent)
case .giftBadge, .text, .payment, .contactShare, .viewOnce, .storyReactionEmoji:
return nil
}
}
private static func displayableTextWithSneakyTransaction(
forPreview quotedMessage: DraftQuotedReplyModel,
spoilerState: SpoilerRenderState
) -> DisplayableText? {
guard
let body = quotedMessage.bodyForSending,
!body.text.isEmpty
else {
return nil
}
return SSKEnvironment.shared.databaseStorageRef.read { tx in
return DisplayableText.displayableText(
withMessageBody: body,
transaction: tx
)
}
}
private func styleDisplayableQuotedText(
_ displayableQuotedText: DisplayableText,
config: HydratedMessageBody.DisplayConfiguration,
quotedReplyModel: DraftQuotedReplyModel,
spoilerState: SpoilerRenderState
) -> NSAttributedString {
let baseAttributes: [NSAttributedString.Key: Any] = [
.font: config.baseFont,
.foregroundColor: config.baseTextColor.forCurrentTheme
]
switch displayableQuotedText.displayTextValue {
case .text(let text):
return NSAttributedString(string: text, attributes: baseAttributes)
case .attributedText(let text):
let mutable = NSMutableAttributedString(attributedString: text)
mutable.addAttributesToEntireString(baseAttributes)
return mutable
case .messageBody(let messageBody):
return messageBody.asAttributedStringForDisplay(
config: config,
isDarkThemeEnabled: Theme.isDarkThemeEnabled
)
}
}
}