Path: blob/main/Signal/ConversationView/Components/CVComponentFooter.swift
1 views
//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import SignalServiceKit
public import SignalUI
public class CVComponentFooter: CVComponentBase, CVComponent {
public var componentKey: CVComponentKey { .footer }
struct StatusIndicator: Equatable {
let imageName: String
let isAnimated: Bool
static var size: CGSize { .init(width: 18, height: 12) }
}
public enum TapForMoreState {
case none
case tapForMore
case undownloadableLongText
var shouldShowFooter: Bool {
switch self {
case .none:
return false
case .tapForMore:
return true
case .undownloadableLongText:
return true
}
}
}
struct State: Equatable {
let timestampText: String
let statusIndicator: StatusIndicator?
let accessibilityLabel: String?
let tapForMoreState: TapForMoreState
let displayEditedLabel: Bool
let isPinnedMessage: Bool
let adminDeleteRecipientAddressStates: AdminDeleteManager.RecipientAddressStates?
struct Expiration: Equatable {
let expirationTimestamp: UInt64
let expiresInSeconds: UInt32
}
let expiration: Expiration?
}
private let footerState: State
public var timestampText: String {
footerState.timestampText
}
public var footerAccessibilityLabel: String? {
footerState.accessibilityLabel
}
private var statusIndicator: StatusIndicator? {
footerState.statusIndicator
}
public var tapForMoreState: TapForMoreState {
footerState.tapForMoreState
}
public var displayEditedLabel: Bool {
footerState.displayEditedLabel
}
private var expiration: State.Expiration? {
footerState.expiration
}
private var isPinnedMessage: Bool {
footerState.isPinnedMessage
}
let isOverlayingMedia: Bool
private let isOutsideBubble: Bool
init(
itemModel: CVItemModel,
footerState: State,
isOverlayingMedia: Bool,
isOutsideBubble: Bool,
) {
self.footerState = footerState
self.isOverlayingMedia = isOverlayingMedia
self.isOutsideBubble = isOutsideBubble
super.init(itemModel: itemModel)
}
public func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView {
CVComponentViewFooter()
}
override public func wallpaperBlurView(componentView: CVComponentView) -> CVWallpaperBlurView? {
guard let componentView = componentView as? CVComponentViewFooter else {
owsFailDebug("Unexpected componentView.")
return nil
}
return componentView.wallpaperBlurView
}
public static let textViewVSpacing: CGFloat = 2
public func configureForRendering(
componentView componentViewParam: CVComponentView,
cellMeasurement: CVCellMeasurement,
componentDelegate: CVComponentDelegate,
) {
guard let componentView = componentViewParam as? CVComponentViewFooter else {
owsFailDebug("Unexpected componentView.")
componentViewParam.reset()
return
}
let outerStack = componentView.outerStack
let innerStack = componentView.innerStack
innerStack.reset()
outerStack.reset()
var outerViews = [UIView]()
var innerViews = [UIView]()
if isBorderless, conversationStyle.hasWallpaper {
let bubbleColor = itemModel.conversationStyle.bubbleChatColor(isIncoming: isIncoming)
let bubbleConfiguration = BubbleConfiguration(
corners: .capsule(),
stroke: itemModel.conversationStyle.bubbleStroke(isIncoming: isIncoming),
)
let bubbleView: UIView
if case .blur = bubbleColor {
let wallpaperBlurView = componentView.ensureWallpaperBlurView()
configureWallpaperBlurView(
wallpaperBlurView: wallpaperBlurView,
componentDelegate: componentDelegate,
bubbleConfig: bubbleConfiguration,
)
bubbleView = wallpaperBlurView
} else {
let chatColorView = componentView.chatColorView
chatColorView.configure(
value: bubbleColor,
referenceView: componentDelegate.view,
bubbleConfig: bubbleConfiguration,
)
bubbleView = chatColorView
}
innerStack.addSubviewToFillSuperviewEdges(bubbleView)
}
if let tapForMoreLabelConfig = self.tapForMoreLabelConfig {
let tapForMoreLabel = componentView.tapForMoreLabel
tapForMoreLabelConfig.applyForRendering(label: tapForMoreLabel)
outerViews.append(tapForMoreLabel)
}
// We always use a stretching spacer.
outerViews.append(UIView.hStretchingSpacer())
outerViews.append(innerStack)
let timestampLabel = componentView.timestampLabel
let textColor: UIColor
if wasRemotelyDeleted, !conversationStyle.hasWallpaper {
owsAssertDebug(!isOverlayingMedia)
textColor = UIColor.Signal.secondaryLabel
} else if isOverlayingMedia {
textColor = .ows_white
} else if isOutsideBubble, !conversationStyle.hasWallpaper {
textColor = Theme.secondaryTextAndIconColor
} else {
textColor = conversationStyle.bubbleSecondaryTextColor(isIncoming: isIncoming)
}
if isPinnedMessage {
let pinIconView = componentView.pinnedImageView
pinIconView.configure(tintColor: textColor)
innerViews.append(pinIconView)
}
if displayEditedLabel {
let editedLabel = componentView.editedLabel
editedLabelConfig(textColor: textColor).applyForRendering(label: editedLabel)
innerViews.append(editedLabel)
}
timestampLabelConfig(textColor: textColor).applyForRendering(label: timestampLabel)
innerViews.append(timestampLabel)
if let expiration {
let messageTimerView = componentView.messageTimerView
messageTimerView.configure(
expirationTimestampMs: expiration.expirationTimestamp,
disappearingMessageInterval: expiration.expiresInSeconds,
tintColor: textColor,
)
innerViews.append(messageTimerView)
}
if isRepresentingSmsMessageRestoredFromBackup {
let smsLockIconView = componentView.smsLockIconView
smsLockIconView.configure(tintColor: textColor)
innerViews.append(smsLockIconView)
}
if let statusIndicator {
if let icon = UIImage(named: statusIndicator.imageName) {
let iconSize = icon.size
let statusIndicatorAreaSize = StatusIndicator.size
owsAssertDebug(iconSize.width <= statusIndicatorAreaSize.width)
owsAssertDebug(iconSize.height == statusIndicatorAreaSize.height)
let statusIndicatorImageView = componentView.statusIndicatorImageView
statusIndicatorImageView.image = icon.withRenderingMode(.alwaysTemplate)
statusIndicatorImageView.tintColor = textColor
// We need exactly the same amount of space for all status indicator images.
// Can't bake the space into icons because some icons are animated.
// The solution is to use a container view.
let statusIndicatorImageViewContainer = UIView(frame: CGRect(origin: .zero, size: statusIndicatorAreaSize))
statusIndicatorImageViewContainer.addSubview(statusIndicatorImageView)
statusIndicatorImageView.frame = CGRect(origin: .zero, size: iconSize)
if CurrentAppContext().isRTL {
statusIndicatorImageView.frame.origin.x = statusIndicatorAreaSize.width - iconSize.width
}
innerViews.append(statusIndicatorImageViewContainer)
if statusIndicator.isAnimated {
componentView.animateSpinningIcon()
}
} else {
owsFailDebug("Missing statusIndicatorImage.")
}
}
innerStack.configure(
config: innerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_innerStack,
subviews: innerViews,
)
outerStack.configure(
config: outerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_outerStack,
subviews: outerViews,
)
}
static func outgoingMessageStatus(interaction: TSInteraction, hasBodyAttachments: Bool) -> MessageReceiptStatus? {
guard let outgoingMessage = interaction as? TSOutgoingMessage else {
return nil
}
return MessageRecipientStatusUtils.recipientStatus(outgoingMessage: outgoingMessage, hasBodyAttachments: hasBodyAttachments)
}
public static func timestampText(
forInteraction interaction: TSInteraction,
shouldUseLongFormat: Bool,
hasBodyAttachments: Bool,
adminDeleteRecipientStates: AdminDeleteManager.RecipientAddressStates?,
) -> String {
let status = Self.outgoingMessageStatus(interaction: interaction, hasBodyAttachments: hasBodyAttachments)
let isPendingOutgoingMessage = status == .pending
let isFailedOutgoingMessage = status == .failed
let isFailedIncomingMessage = AdminDeleteManager.isFailedAdminDelete(recipientAddressStates: adminDeleteRecipientStates)
let wasSentToAnyRecipient: Bool = {
if let outgoingMessage = interaction as? TSOutgoingMessage {
return outgoingMessage.wasSentToAnyRecipient
}
if let message = interaction as? TSMessage, message.wasRemotelyDeleted {
return AdminDeleteManager.wasSentToAnyRecipient(recipientAddressStates: adminDeleteRecipientStates)
}
return false
}()
if isPendingOutgoingMessage {
return OWSLocalizedString(
"MESSAGE_STATUS_PENDING",
comment: "Label indicating that a message send was paused.",
)
} else if isFailedOutgoingMessage || isFailedIncomingMessage {
if wasSentToAnyRecipient {
return OWSLocalizedString(
"MESSAGE_STATUS_PARTIALLY_SENT",
comment: "Label indicating that a message was only sent to some recipients.",
)
} else {
return OWSLocalizedString(
"MESSAGE_STATUS_SEND_FAILED",
comment: "Label indicating that a message failed to send.",
)
}
} else {
return DateUtil.formatMessageTimestampForCVC(
interaction.timestamp,
shouldUseLongFormat: shouldUseLongFormat,
)
}
}
static func buildPaymentState(
interaction: TSInteraction,
paymentNotification: TSPaymentNotification?,
tapForMoreState: TapForMoreState,
transaction: DBReadTransaction,
) -> State {
guard
let receiptData = paymentNotification?.mcReceiptData,
let paymentModel = PaymentFinder.paymentModels(
forMcReceiptData: receiptData,
transaction: transaction,
).first
else {
let hasBodyAttachments = (interaction as? TSMessage)?.hasBodyAttachments(transaction: transaction) ?? false
let timestampText = Self.timestampText(
forInteraction: interaction,
shouldUseLongFormat: false,
hasBodyAttachments: hasBodyAttachments,
adminDeleteRecipientStates: nil,
)
return State(
timestampText: timestampText,
statusIndicator: nil,
accessibilityLabel: nil,
tapForMoreState: tapForMoreState,
displayEditedLabel: false,
isPinnedMessage: false,
adminDeleteRecipientAddressStates: nil,
expiration: nil,
)
}
let timestampText = Self.paymentMessageTimestampText(
forInteraction: interaction,
paymentState: paymentModel.paymentState,
shouldUseLongFormat: false,
)
var statusIndicator: StatusIndicator?
var accessibilityLabel: String?
if let outgoingMessage = interaction as? TSOutgoingMessage {
let messageStatus = MessageRecipientStatusUtils.recipientStatus(
outgoingMessage: outgoingMessage,
paymentModel: paymentModel,
)
accessibilityLabel = MessageRecipientStatusUtils.receiptMessage(
outgoingMessage: outgoingMessage,
paymentModel: paymentModel,
)
switch messageStatus {
case .uploading, .sending:
statusIndicator = StatusIndicator(
imageName: "message_status_sending",
isAnimated: true,
)
case .pending:
statusIndicator = StatusIndicator(
imageName: "message_status_sending",
isAnimated: false,
)
case .sent, .skipped:
statusIndicator = StatusIndicator(
imageName: "message_status_sent",
isAnimated: false,
)
case .delivered:
statusIndicator = StatusIndicator(
imageName: "message_status_delivered",
isAnimated: false,
)
case .read, .viewed:
statusIndicator = StatusIndicator(
imageName: "message_status_read",
isAnimated: false,
)
case .failed:
// No status indicator icon.
break
}
if outgoingMessage.wasRemotelyDeleted {
statusIndicator = nil
}
}
var expiration: State.Expiration?
if
let message = interaction as? TSMessage,
message.hasPerConversationExpiration
{
expiration = State.Expiration(
expirationTimestamp: message.expiresAt,
expiresInSeconds: message.expiresInSeconds,
)
}
return State(
timestampText: timestampText,
statusIndicator: statusIndicator,
accessibilityLabel: accessibilityLabel,
tapForMoreState: tapForMoreState,
displayEditedLabel: false,
isPinnedMessage: false,
adminDeleteRecipientAddressStates: nil,
expiration: expiration,
)
}
public static func paymentMessageTimestampText(
forInteraction interaction: TSInteraction,
paymentState: TSPaymentState,
shouldUseLongFormat: Bool,
) -> String {
switch paymentState.messageReceiptStatus {
case .pending:
return OWSLocalizedString(
"MESSAGE_STATUS_PENDING",
comment: "Label indicating that a message send was paused.",
)
case .failed:
return OWSLocalizedString(
"MESSAGE_STATUS_SEND_FAILED",
comment: "Label indicating that a message failed to send.",
)
default:
return DateUtil.formatMessageTimestampForCVC(
interaction.timestamp,
shouldUseLongFormat: shouldUseLongFormat,
)
}
}
static func buildState(
interaction: TSInteraction,
tapForMoreState: TapForMoreState,
isPinnedMessage: Bool,
adminDeleteRecipientStates: AdminDeleteManager.RecipientAddressStates?,
transaction: DBReadTransaction,
) -> State {
let hasBodyAttachments = (interaction as? TSMessage)?.hasBodyAttachments(transaction: transaction) ?? false
let timestampText = Self.timestampText(
forInteraction: interaction,
shouldUseLongFormat: false,
hasBodyAttachments: hasBodyAttachments,
adminDeleteRecipientStates: adminDeleteRecipientStates,
)
var statusIndicator: StatusIndicator?
var accessibilityLabel: String?
if let outgoingMessage = interaction as? TSOutgoingMessage {
let (messageStatus, label) = MessageRecipientStatusUtils.receiptStatusAndMessage(
outgoingMessage: outgoingMessage,
transaction: transaction,
)
accessibilityLabel = label
switch messageStatus {
case .uploading, .sending:
statusIndicator = StatusIndicator(
imageName: "message_status_sending",
isAnimated: true,
)
case .pending:
statusIndicator = StatusIndicator(
imageName: "message_status_sending",
isAnimated: false,
)
case .sent, .skipped:
statusIndicator = StatusIndicator(
imageName: "message_status_sent",
isAnimated: false,
)
case .delivered:
statusIndicator = StatusIndicator(
imageName: "message_status_delivered",
isAnimated: false,
)
case .read, .viewed:
statusIndicator = StatusIndicator(
imageName: "message_status_read",
isAnimated: false,
)
case .failed:
// No status indicator icon.
break
}
if outgoingMessage.wasRemotelyDeleted {
if messageStatus != .uploading, messageStatus != .sending {
statusIndicator = nil
}
}
} else if
let incomingMessage = interaction as? TSIncomingMessage,
incomingMessage.wasRemotelyDeleted,
let adminDeleteRecipientStates
{
let messageStatus = TSMessage.messageStateForRecipientStates(Array(adminDeleteRecipientStates.values))
switch messageStatus {
case .sending:
statusIndicator = StatusIndicator(
imageName: "message_status_sending",
isAnimated: true,
)
case .pending, .sent, .sent_OBSOLETE, .delivered_OBSOLETE, .failed:
statusIndicator = nil
}
}
var expiration: State.Expiration?
var displayEditedLabel: Bool = false
if let message = interaction as? TSMessage {
if message.hasPerConversationExpiration {
expiration = State.Expiration(
expirationTimestamp: message.expiresAt,
expiresInSeconds: message.expiresInSeconds,
)
}
if !message.wasRemotelyDeleted {
switch message.editState {
case .latestRevisionRead, .latestRevisionUnread:
displayEditedLabel = true
case .none, .pastRevision:
displayEditedLabel = false
}
}
}
return State(
timestampText: timestampText,
statusIndicator: statusIndicator,
accessibilityLabel: accessibilityLabel,
tapForMoreState: tapForMoreState,
displayEditedLabel: displayEditedLabel,
isPinnedMessage: isPinnedMessage,
adminDeleteRecipientAddressStates: adminDeleteRecipientStates,
expiration: expiration,
)
}
private func editedLabelConfig(textColor: UIColor) -> CVLabelConfig {
let text = OWSLocalizedString(
"MESSAGE_STATUS_EDITED",
comment: "status meesage for edited messages",
)
return CVLabelConfig.unstyledText(
text,
font: .dynamicTypeCaption1,
textColor: textColor,
)
}
private func timestampLabelConfig(textColor: UIColor) -> CVLabelConfig {
return CVLabelConfig.unstyledText(
timestampText,
font: .dynamicTypeCaption1,
textColor: textColor,
)
}
private var tapForMoreLabelConfig: CVLabelConfig? {
switch tapForMoreState {
case .none:
return nil
case .tapForMore:
guard !wasRemotelyDeleted else {
return nil
}
guard let message = interaction as? TSMessage else {
owsFailDebug("Invalid interaction.")
return nil
}
let text = OWSLocalizedString(
"CONVERSATION_VIEW_OVERSIZE_TEXT_TAP_FOR_MORE",
comment: "Indicator on truncated text messages that they can be tapped to see the entire text message.",
)
return CVLabelConfig.unstyledText(
text,
font: UIFont.dynamicTypeSubheadlineClamped.semibold(),
textColor: conversationStyle.bubbleReadMoreTextColor(message: message),
textAlignment: .trailing,
)
case .undownloadableLongText:
guard !wasRemotelyDeleted else {
return nil
}
guard let message = interaction as? TSMessage else {
owsFailDebug("Invalid interaction.")
return nil
}
let font = UIFont.dynamicTypeFootnoteClamped.semibold()
let textColor = conversationStyle.bubbleReadMoreTextColor(message: message)
let attributedString = NSAttributedString.composed(of: [
NSAttributedString.with(
image: UIImage(named: "error-circle-20")!,
font: font,
),
" ",
OWSLocalizedString(
"OVERSIZE_TEXT_UNAVAILABLE_FOOTER",
comment: "Footer for message cell for long text when it is expired and unavailable for download",
),
" ",
NSAttributedString.with(
image: UIImage(named: "chevron-right-20")!,
font: font,
),
])
// TODO[AttachmentRendering]: have to render a horizontal line
// above the text when showing undownloadable state.
return CVLabelConfig(
text: .attributedText(attributedString),
displayConfig: .forUnstyledText(font: font, textColor: textColor),
font: font,
textColor: textColor,
textAlignment: .trailing,
)
}
}
private let tapForMoreHeightFactor: CGFloat = 1.25
private var outerStackConfig: CVStackViewConfig {
CVStackViewConfig(
axis: .horizontal,
alignment: .bottom,
spacing: CVComponentFooter.hSpacing,
layoutMargins: .zero,
)
}
private var innerStackConfig: CVStackViewConfig {
let layoutMargins = isBorderless ? UIEdgeInsets(hMargin: 12, vMargin: 3) : .zero
return CVStackViewConfig(
axis: .horizontal,
alignment: .center,
spacing: CVComponentFooter.hSpacing,
layoutMargins: layoutMargins,
)
}
private static let measurementKey_outerStack = "CVComponentFooter.measurementKey_outerStack"
private static let measurementKey_innerStack = "CVComponentFooter.measurementKey_innerStack"
// Extract the overall measurement for this component.
public static func footerMeasurement(measurementBuilder: CVCellMeasurement.Builder) -> CVCellMeasurement.Measurement? {
measurementBuilder.getMeasurement(key: measurementKey_outerStack)
}
public func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
owsAssertDebug(maxWidth > 0)
var outerSubviewInfos = [ManualStackSubviewInfo]()
var innerSubviewInfos = [ManualStackSubviewInfo]()
if let tapForMoreLabelConfig = self.tapForMoreLabelConfig {
var tapForMoreSize = CVText.measureLabel(
config: tapForMoreLabelConfig,
maxWidth: maxWidth,
)
tapForMoreSize.height *= tapForMoreHeightFactor
outerSubviewInfos.append(tapForMoreSize.asManualSubviewInfo(hasFixedWidth: true))
}
// We always use a stretching spacer.
outerSubviewInfos.append(ManualStackSubviewInfo.empty)
if footerState.isPinnedMessage {
let pinIconSize = PinnedMessageIconView.size
innerSubviewInfos.append(pinIconSize.asManualSubviewInfo(hasFixedWidth: true))
}
if displayEditedLabel {
let editedLabelConfig = self.editedLabelConfig(textColor: .black)
let editedLabelSize = CVText.measureLabel(config: editedLabelConfig, maxWidth: maxWidth)
innerSubviewInfos.append(editedLabelSize.asManualSubviewInfo(hasFixedWidth: true))
}
// The color doesn't matter for measurement.
let timestampLabelConfig = self.timestampLabelConfig(textColor: UIColor.black)
let timestampLabelSize = CVText.measureLabel(
config: timestampLabelConfig,
maxWidth: maxWidth,
)
innerSubviewInfos.append(timestampLabelSize.asManualSubviewInfo(hasFixedWidth: true))
if
hasPerConversationExpiration,
interaction is TSMessage
{
let timerSize = MessageTimerView.measureSize
innerSubviewInfos.append(timerSize.asManualSubviewInfo(hasFixedWidth: true))
}
if isRepresentingSmsMessageRestoredFromBackup {
let lockIconSize = SmsLockIconView.size
innerSubviewInfos.append(lockIconSize.asManualSubviewInfo(hasFixedWidth: true))
}
if statusIndicator != nil {
let statusSize = StatusIndicator.size
innerSubviewInfos.append(statusSize.asManualSubviewInfo(hasFixedWidth: true))
}
let innerStackMeasurement = ManualStackView.measure(
config: innerStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_innerStack,
subviewInfos: innerSubviewInfos,
)
outerSubviewInfos.append(innerStackMeasurement.measuredSize.asManualSubviewInfo(hasFixedWidth: true))
let outerStackMeasurement = ManualStackView.measure(
config: outerStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_outerStack,
subviewInfos: outerSubviewInfos,
maxWidth: maxWidth,
)
return outerStackMeasurement.measuredSize
}
private static let hSpacing: CGFloat = 4
// MARK: - Events
override public func handleTap(
sender: UIGestureRecognizer,
componentDelegate: CVComponentDelegate,
componentView: CVComponentView,
renderItem: CVRenderItem,
) -> Bool {
guard let componentView = componentView as? CVComponentViewFooter else {
owsFailDebug("Unexpected componentView.")
return false
}
switch tapForMoreState {
case .none:
break
case .tapForMore, .undownloadableLongText:
let readMoreLabel = componentView.tapForMoreLabel
let location = sender.location(in: readMoreLabel)
if readMoreLabel.bounds.contains(location) {
let itemViewModel = CVItemViewModelImpl(renderItem: renderItem)
switch tapForMoreState {
case .none:
break
case .tapForMore:
componentDelegate.didTapTruncatedTextMessage(itemViewModel)
case .undownloadableLongText:
componentDelegate.didTapUndownloadableOversizeText()
}
return true
}
}
if displayEditedLabel {
let editedLabel = componentView.editedLabel
let location = sender.location(in: editedLabel)
if editedLabel.bounds.contains(location) {
let itemViewModel = CVItemViewModelImpl(renderItem: renderItem)
componentDelegate.didTapShowEditHistory(itemViewModel)
return true
}
}
return false
}
// MARK: -
// Used for rendering some portion of an Conversation View item.
// It could be the entire item or some part thereof.
public class CVComponentViewFooter: NSObject, CVComponentView {
fileprivate let outerStack = ManualStackView(name: "footer.outerStack")
fileprivate let innerStack = ManualStackViewWithLayer(name: "footer.innerStack")
fileprivate let tapForMoreLabel = CVLabel()
fileprivate let editedLabel = CVLabel()
fileprivate let timestampLabel = CVLabel()
fileprivate let statusIndicatorImageView = CVImageView()
fileprivate let messageTimerView = MessageTimerView()
fileprivate let smsLockIconView = SmsLockIconView()
fileprivate let pinnedImageView = PinnedMessageIconView()
// Bubble view when there is no chat wallpaper.
fileprivate let chatColorView = CVColorOrGradientView()
// Bubble view when there is a chat wallpaper.
fileprivate var wallpaperBlurView: CVWallpaperBlurView?
fileprivate func ensureWallpaperBlurView() -> CVWallpaperBlurView {
if let wallpaperBlurView {
return wallpaperBlurView
}
let wallpaperBlurView = CVWallpaperBlurView()
self.wallpaperBlurView = wallpaperBlurView
return wallpaperBlurView
}
public var isDedicatedCellView = false
public var rootView: UIView {
outerStack
}
override init() {
timestampLabel.textAlignment = .trailing
}
public func setIsCellVisible(_ isCellVisible: Bool) {}
public func reset() {
outerStack.reset()
innerStack.reset()
innerStack.backgroundColor = nil
tapForMoreLabel.text = nil
editedLabel.text = nil
timestampLabel.text = nil
statusIndicatorImageView.image = nil
statusIndicatorImageView.layer.removeAllAnimations()
messageTimerView.prepareForReuse()
messageTimerView.removeFromSuperview()
smsLockIconView.removeFromSuperview()
pinnedImageView.removeFromSuperview()
chatColorView.reset()
chatColorView.removeFromSuperview()
wallpaperBlurView?.removeFromSuperview()
}
fileprivate func animateSpinningIcon() {
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
animation.toValue = CGFloat.pi * 2
animation.duration = TimeInterval.second
animation.isCumulative = true
animation.repeatCount = .greatestFiniteMagnitude
statusIndicatorImageView.layer.add(animation, forKey: "animation")
}
}
}
// MARK: -
private extension CVComponentFooter {
/// Is this footer representing an SMS message we restored from a Backup?
///
/// If so, we want to add some UI to indicate such, matching the UI for
/// these on Android, where they originated.
var isRepresentingSmsMessageRestoredFromBackup: Bool {
if
let message = interaction as? TSMessage,
message.isSmsMessageRestoredFromBackup
{
return true
}
return false
}
}