Path: blob/main/Signal/ConversationView/ConversationInputToolbar+QuotedReplyPreview.swift
1 views
//
// 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 {
weak var delegate: QuotedReplyPreviewDelegate?
private let quotedReplyDraft: DraftQuotedReplyModel
private let spoilerState: SpoilerRenderState
private var quotedMessageView: QuotedMessageSnippetView?
private var heightConstraint: NSLayoutConstraint!
private weak var contentView: UIView?
@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,
spoilerState: SpoilerRenderState,
) {
self.quotedReplyDraft = quotedReplyDraft
self.spoilerState = spoilerState
super.init(frame: .zero)
directionalLayoutMargins = .init(hMargin: 8, vMargin: 0)
contentView = self
// Background with rounded corners.
let backgroundView: UIView
if #available(iOS 26, *) {
clipsToBounds = true
cornerConfiguration = .uniformCorners(radius: .containerConcentric(minimum: 12))
let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThinMaterial))
// Colored overlay on top of blur.
let dimmingView = UIView()
dimmingView.backgroundColor = .Signal.secondaryFill
dimmingView.translatesAutoresizingMaskIntoConstraints = false
blurEffectView.contentView.addSubview(dimmingView)
NSLayoutConstraint.activate([
dimmingView.topAnchor.constraint(equalTo: blurEffectView.topAnchor),
dimmingView.leadingAnchor.constraint(equalTo: blurEffectView.leadingAnchor),
dimmingView.trailingAnchor.constraint(equalTo: blurEffectView.trailingAnchor),
dimmingView.bottomAnchor.constraint(equalTo: blurEffectView.bottomAnchor),
])
contentView = blurEffectView.contentView
backgroundView = blurEffectView
} else {
let maskLayer = CAShapeLayer()
backgroundView = OWSLayerView(
frame: .zero,
layoutCallback: { layerView in
maskLayer.path = UIBezierPath(roundedRect: layerView.bounds, cornerRadius: 12).cgPath
},
)
backgroundView.layer.mask = maskLayer
backgroundView.backgroundColor = .Signal.secondaryFill
}
backgroundView.translatesAutoresizingMaskIntoConstraints = false
addSubview(backgroundView)
addConstraints([
backgroundView.topAnchor.constraint(equalTo: topAnchor),
backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
reloadMessageSnippet()
// Quoted message text is complicated and is constructed via AttributedString.
// Simply reload message preview view when font size changes.
NotificationCenter.default.addObserver(
self,
selector: #selector(contentSizeCategoryDidChange),
name: UIContentSizeCategory.didChangeNotification,
object: nil,
)
}
private func reloadMessageSnippet() {
if let quotedMessageView {
quotedMessageView.removeFromSuperview()
}
// 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,
spoilerState: spoilerState,
)
quotedMessageView.delegate = self
quotedMessageView.translatesAutoresizingMaskIntoConstraints = false
let contentView = contentView ?? self
contentView.addSubview(quotedMessageView)
addConstraints([
quotedMessageView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
quotedMessageView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
quotedMessageView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
quotedMessageView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
])
self.quotedMessageView = quotedMessageView
}
@objc
private func contentSizeCategoryDidChange(_ notification: Notification) {
reloadMessageSnippet()
}
// 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 spoilerState: SpoilerRenderState
private lazy var displayableQuotedText: DisplayableText? = {
QuotedMessageSnippetView.displayableTextWithSneakyTransaction(
forPreview: quotedMessage,
spoilerState: spoilerState,
)
}()
init(
quotedMessage: DraftQuotedReplyModel,
spoilerState: SpoilerRenderState,
) {
self.quotedMessage = quotedMessage
self.spoilerState = spoilerState
super.init(frame: .zero)
isUserInteractionEnabled = true
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.nonPluralLocalizedStringWithFormat(
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 = ConversationInputToolbar.Style.primaryTextColor
label.lineBreakMode = .byTruncatingTail
label.numberOfLines = 1
label.setContentHuggingVerticalHigh()
label.setContentHuggingHorizontalLow()
label.setCompressionResistanceVerticalHigh()
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,
!quotedMessage.content.isPoll
{
let config = HydratedMessageBody.DisplayConfiguration.quotedReply(
font: Layout.quotedTextFont,
textColor: .fixed(ConversationInputToolbar.Style.primaryTextColor),
)
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
case .attachmentStub(_, let stub) = quotedMessage.content,
stub.renderingFlag == .voiceMessage
{
let iconPrefix = SignalSymbol.audioSquare.attributedString(dynamicTypeBaseSize: Layout.fileTypeFont.pointSize)
attributedText = iconPrefix + " " + OWSLocalizedString(
"QUOTED_REPLY_TYPE_VOICE_MESSAGE",
comment: "Indicates this message is a quoted reply to a voice message.",
)
} else if let fileTypeForSnippet {
attributedText = NSAttributedString(
string: fileTypeForSnippet,
attributes: [
.font: Layout.fileTypeFont,
.foregroundColor: ConversationInputToolbar.Style.secondaryTextColor,
],
)
} else if let sourceFilename = sourceFilenameForSnippet(quotedMessage.content)?.filterForDisplay {
attributedText = NSAttributedString(
string: sourceFilename,
attributes: [
.font: Layout.filenameFont,
.foregroundColor: ConversationInputToolbar.Style.secondaryTextColor,
],
)
} 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: ConversationInputToolbar.Style.secondaryTextColor,
],
)
} else if quotedMessage.content.isPoll {
switch quotedMessage.content {
case .poll(let pollQuestion):
let pollIcon = SignalSymbol.poll.attributedString(dynamicTypeBaseSize: Layout.fileTypeFont.pointSize) + " "
let pollPrefix = OWSLocalizedString(
"POLL_LABEL",
comment: "Label specifying the message type as a poll",
) + ": "
attributedText = pollIcon + NSAttributedString(
string: pollPrefix + pollQuestion,
attributes: [
.font: Layout.fileTypeFont,
.foregroundColor: ConversationInputToolbar.Style.secondaryTextColor,
],
)
default:
owsFailDebug("Quoted message is poll but there's no poll")
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: ConversationInputToolbar.Style.secondaryTextColor,
],
)
}
} 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: ConversationInputToolbar.Style.secondaryTextColor,
],
)
}
label.numberOfLines = 2
label.lineBreakMode = .byTruncatingTail
label.textAlignment = displayableQuotedText?.displayTextNaturalAlignment ?? .natural
label.attributedText = attributedText
label.setContentHuggingVerticalHigh()
label.setContentHuggingHorizontalLow()
label.setCompressionResistanceVerticalHigh()
label.setCompressionResistanceHorizontalLow()
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 var quotedAuthorFont: UIFont {
UIFont.dynamicTypeFootnoteClamped.semibold()
}
static var quotedTextFont: UIFont {
.dynamicTypeSubheadlineClamped
}
static var filenameFont: UIFont {
quotedTextFont
}
static var fileTypeFont: UIFont {
quotedTextFont.italic()
}
static let quotedAttachmentSize: CGFloat = 54
static let remotelySourcedContentGlyphLength: CGFloat = 16
}
private func createViewContents() {
// Quoted text and message author, media thumbnail if any.
let horizonalStack = UIStackView(arrangedSubviews: [])
horizonalStack.axis = .horizontal
horizonalStack.spacing = 8
let stripeView = UIView()
stripeView.backgroundColor = .Signal.quaternaryLabel
horizonalStack.addArrangedSubview(stripeView)
if #available(iOS 26, *) {
stripeView.cornerConfiguration = .capsule()
}
let textStack = UIStackView(arrangedSubviews: [quotedAuthorLabel, quotedTextLabel])
textStack.axis = .vertical
textStack.spacing = 2
// Putting vertical stack in a container allows to center that text stack vertically
// when the image is taller than text, as well as add top and bottom margins.
let textStackContainer = UIView.container()
textStackContainer.addSubview(textStack)
textStack.translatesAutoresizingMaskIntoConstraints = false
textStackContainer.addSubview(stripeView)
stripeView.translatesAutoresizingMaskIntoConstraints = false
textStackContainer.addConstraints([
stripeView.leadingAnchor.constraint(equalTo: textStackContainer.leadingAnchor),
stripeView.widthAnchor.constraint(equalToConstant: 4),
stripeView.topAnchor.constraint(equalTo: textStack.topAnchor),
stripeView.bottomAnchor.constraint(equalTo: textStack.bottomAnchor),
textStack.leadingAnchor.constraint(equalTo: stripeView.trailingAnchor, constant: 8),
textStack.topAnchor.constraint(greaterThanOrEqualTo: textStackContainer.topAnchor, constant: 8),
{
let c = textStack.topAnchor.constraint(equalTo: textStackContainer.topAnchor, constant: 8)
c.priority = .defaultLow
return c
}(),
textStack.centerYAnchor.constraint(equalTo: textStackContainer.centerYAnchor),
textStack.trailingAnchor.constraint(equalTo: textStackContainer.trailingAnchor),
])
horizonalStack.addArrangedSubview(textStackContainer)
createContentView(for: quotedMessage.content, in: horizonalStack)
// If there's no local copy of the quoted message we display some extra text below
// by wrapping what we have so far in a vertical stack view.
let contentView: UIView
if quotedMessage.content.isRemotelySourced {
let quoteSourceWrapper = UIStackView(arrangedSubviews: [horizonalStack, buildRemoteContentSourceView()])
quoteSourceWrapper.axis = .vertical
contentView = quoteSourceWrapper
} else {
contentView = horizonalStack
}
// (X) button.
let cancelButton = UIButton(
configuration: .bordered(),
primaryAction: UIAction { [weak self] _ in
self?.didTapCancel()
},
)
cancelButton.configuration?.image = UIImage(imageLiteralResourceName: "x-compact-bold")
cancelButton.configuration?.baseBackgroundColor = UIColor(
light: UIColor(rgbHex: 0xF5F5F5, alpha: 0.9),
dark: UIColor(rgbHex: 0x787880, alpha: 0.4),
)
cancelButton.configuration?.background.visualEffect = UIBlurEffect(style: .systemUltraThinMaterial)
cancelButton.tintColor = ConversationInputToolbar.Style.primaryTextColor
cancelButton.configuration?.cornerStyle = .capsule
cancelButton.setContentHuggingHorizontalHigh()
cancelButton.setCompressionResistanceHorizontalHigh()
// Put the button in a container and align it to the top.
let cancelButtonContainer = UIView.container()
cancelButtonContainer.addSubview(cancelButton)
cancelButton.translatesAutoresizingMaskIntoConstraints = false
cancelButtonContainer.addConstraints([
cancelButton.widthAnchor.constraint(equalToConstant: 24),
cancelButton.heightAnchor.constraint(equalToConstant: 24),
cancelButton.topAnchor.constraint(equalTo: cancelButtonContainer.topAnchor, constant: 8),
cancelButton.leadingAnchor.constraint(equalTo: cancelButtonContainer.leadingAnchor),
cancelButton.trailingAnchor.constraint(equalTo: cancelButtonContainer.trailingAnchor),
cancelButton.bottomAnchor.constraint(lessThanOrEqualTo: cancelButtonContainer.bottomAnchor),
])
// One more horizontal stack to hold everything.
let outermostHStack = UIStackView(arrangedSubviews: [contentView, cancelButtonContainer])
outermostHStack.axis = .horizontal
outermostHStack.spacing = 8
outermostHStack.translatesAutoresizingMaskIntoConstraints = false
addSubview(outermostHStack)
addConstraints([
outermostHStack.topAnchor.constraint(equalTo: topAnchor),
outermostHStack.leadingAnchor.constraint(equalTo: leadingAnchor),
outermostHStack.trailingAnchor.constraint(equalTo: trailingAnchor),
outermostHStack.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
private func createContentView(for content: DraftQuotedReplyModel.Content, in stackView: UIStackView) {
var thumbnailView: UIView?
switch content {
case let .attachment(_, _, attachment, thumbnailImage):
thumbnailView = createAttachmentView(attachment, thumbnailImage: thumbnailImage)
case .attachmentStub(_, let stub):
switch stub.renderingFlag {
case .voiceMessage:
break
case nil, .default, .borderless, .shouldLoop:
thumbnailView = createStubAttachmentView()
}
case let .edit(_, _, content):
createContentView(for: content, in: stackView)
return
case .giftBadge:
let imageView = buildImageView(image: UIImage(imageLiteralResourceName: "gift-thumbnail"))
imageView.contentMode = .scaleAspectFit
thumbnailView = imageView
case .payment, .text, .viewOnce, .contactShare, .storyReactionEmoji, .poll:
break
}
guard let thumbnailView else { return }
let containerView = UIView.container()
containerView.addSubview(thumbnailView)
thumbnailView.translatesAutoresizingMaskIntoConstraints = false
containerView.addConstraints([
thumbnailView.topAnchor.constraint(equalTo: containerView.topAnchor),
thumbnailView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
thumbnailView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
thumbnailView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
// Always fixed width.
thumbnailView.widthAnchor.constraint(equalToConstant: Layout.quotedAttachmentSize),
// Stretch thumbnail to fill height if text requires more vertical space than
// default height of the thumbnail provides.
{
let c = thumbnailView.heightAnchor.constraint(equalToConstant: Layout.quotedAttachmentSize)
// Lower than vertical compression resistance on the text labels.
c.priority = .defaultHigh
return c
}(),
])
stackView.addArrangedSubview(containerView)
}
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.translatesAutoresizingMaskIntoConstraints = false
contentImageView.addConstraints([
playIconImageView.centerYAnchor.constraint(equalTo: contentImageView.centerYAnchor),
playIconImageView.centerXAnchor.constraint(equalTo: contentImageView.centerXAnchor),
])
}
quotedAttachmentView = contentImageView
} else if attachment.asAnyPointer() != nil {
let refreshIcon = buildImageView(image: UIImage(imageLiteralResourceName: "refresh"))
refreshIcon.contentMode = .scaleAspectFit
refreshIcon.tintColor = .Signal.tertiaryLabel
let containerView = UIView.container()
containerView.backgroundColor = .Signal.tertiaryBackground
containerView.addSubview(refreshIcon)
refreshIcon.translatesAutoresizingMaskIntoConstraints = false
containerView.addConstraints([
refreshIcon.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
refreshIcon.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
])
quotedAttachmentView = containerView
} else {
quotedAttachmentView = createStubAttachmentView()
}
return quotedAttachmentView
}
// Return generic attachment image centered in a container view.
private func createStubAttachmentView() -> UIView {
let imageView = buildImageView(image: .genericAttachment)
imageView.contentMode = .scaleAspectFit
let containerView = UIView.container()
containerView.addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
containerView.addConstraints([
imageView.topAnchor.constraint(greaterThanOrEqualTo: containerView.topAnchor),
imageView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
imageView.leadingAnchor.constraint(greaterThanOrEqualTo: containerView.leadingAnchor),
imageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
])
return containerView
}
private func didTapCancel() {
delegate?.didTapCancelInQuotedMessageSnippet(view: self)
}
// MARK: -
private func mimeTypeAndRenderingFlag(
_ content: DraftQuotedReplyModel.Content,
) -> (String, AttachmentReference.RenderingFlag?)? {
switch content {
case .attachmentStub(_, let stub):
if let mimeType = stub.mimeType {
return (mimeType, stub.renderingFlag)
} else {
return nil
}
case .attachment(_, let reference, let attachment, _):
return (attachment.mimeType, reference.renderingFlag)
case .edit(_, _, let innerContent):
return mimeTypeAndRenderingFlag(innerContent)
case .giftBadge, .text, .payment, .viewOnce, .contactShare, .storyReactionEmoji, .poll:
return nil
}
}
private var fileTypeForSnippet: String? {
guard let (mimeType, renderingFlag) = mimeTypeAndRenderingFlag(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 MimeTypeUtil.isSupportedVideoMimeType(mimeType) {
if renderingFlag == .shouldLoop {
return NSLocalizedString(
"QUOTED_REPLY_TYPE_GIF",
comment: "Indicates this message is a quoted reply to animated GIF file.",
)
} else {
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, .poll:
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,
)
}
}
}