Signal-iOS/Signal/src/ViewControllers/ConversationView/CV/CVComponents/CVComponentFooter.swift
Igor Solomennikov 0c42c42eca
Remove "cvc_" prefix chat view code
This change should have no user impact.
2022-11-15 10:31:10 -06:00

426 lines
18 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import SignalMessaging
public class CVComponentFooter: CVComponentBase, CVComponent {
public var componentKey: CVComponentKey { .footer }
struct StatusIndicator: Equatable {
let imageName: String
let imageSize: CGSize
let isAnimated: Bool
}
struct State: Equatable {
let timestampText: String
let statusIndicator: StatusIndicator?
let accessibilityLabel: String?
let hasTapForMore: Bool
struct Expiration: Equatable {
let expirationTimestamp: UInt64
let expiresInSeconds: UInt32
}
let expiration: Expiration?
}
private let footerState: State
public var timestampText: String {
footerState.timestampText
}
private var statusIndicator: StatusIndicator? {
footerState.statusIndicator
}
public var hasTapForMore: Bool {
footerState.hasTapForMore
}
private var expiration: State.Expiration? {
footerState.expiration
}
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()
}
public override func updateScrollingContent(componentView: CVComponentView) {
super.updateScrollingContent(componentView: componentView)
guard let componentView = componentView as? CVComponentViewFooter else {
owsFailDebug("Unexpected componentView.")
return
}
componentView.chatColorView.updateAppearance()
}
public static let textViewVSpacing: CGFloat = 2
public static let bodyMediaQuotedReplyVSpacing: CGFloat = 6
public static let quotedReplyTopMargin: CGFloat = 6
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 chatColorView = componentView.chatColorView
chatColorView.configure(value: conversationStyle.bubbleChatColor(isIncoming: isIncoming),
referenceView: componentDelegate.view,
hasPillRounding: true)
innerStack.addSubviewToFillSuperviewEdges(chatColorView)
}
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 = Theme.primaryTextColor
} else if isOverlayingMedia {
textColor = .ows_white
} else if isOutsideBubble && !conversationStyle.hasWallpaper {
textColor = Theme.secondaryTextAndIconColor
} else {
textColor = conversationStyle.bubbleSecondaryTextColor(isIncoming: isIncoming)
}
timestampLabelConfig(textColor: textColor).applyForRendering(label: timestampLabel)
innerViews.append(timestampLabel)
if let expiration = expiration {
let messageTimerView = componentView.messageTimerView
messageTimerView.configure(expirationTimestamp: expiration.expirationTimestamp,
initialDurationSeconds: expiration.expiresInSeconds,
tintColor: textColor)
innerViews.append(messageTimerView)
}
if let statusIndicator = self.statusIndicator {
if let icon = UIImage(named: statusIndicator.imageName) {
let statusIndicatorImageView = componentView.statusIndicatorImageView
owsAssertDebug(icon.size == statusIndicator.imageSize)
statusIndicatorImageView.image = icon.withRenderingMode(.alwaysTemplate)
statusIndicatorImageView.tintColor = textColor
innerViews.append(statusIndicatorImageView)
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 isPendingOutgoingMessage(interaction: TSInteraction) -> Bool {
guard let outgoingMessage = interaction as? TSOutgoingMessage else {
return false
}
let messageStatus = MessageRecipientStatusUtils.recipientStatus(outgoingMessage: outgoingMessage)
return messageStatus == .pending
}
static func isFailedOutgoingMessage(interaction: TSInteraction) -> Bool {
guard let outgoingMessage = interaction as? TSOutgoingMessage else {
return false
}
let messageStatus = MessageRecipientStatusUtils.recipientStatus(outgoingMessage: outgoingMessage)
return messageStatus == .failed
}
public static func timestampText(forInteraction interaction: TSInteraction,
shouldUseLongFormat: Bool) -> String {
let isPendingOutgoingMessage = Self.isPendingOutgoingMessage(interaction: interaction)
let isFailedOutgoingMessage = Self.isFailedOutgoingMessage(interaction: interaction)
let wasSentToAnyRecipient: Bool = {
guard let outgoingMessage = interaction as? TSOutgoingMessage else {
return false
}
return outgoingMessage.wasSentToAnyRecipient
}()
if isPendingOutgoingMessage {
return NSLocalizedString("MESSAGE_STATUS_PENDING",
comment: "Label indicating that a message send was paused.")
} else if isFailedOutgoingMessage {
if wasSentToAnyRecipient {
return NSLocalizedString("MESSAGE_STATUS_PARTIALLY_SENT",
comment: "Label indicating that a message was only sent to some recipients.")
} else {
return NSLocalizedString("MESSAGE_STATUS_SEND_FAILED",
comment: "Label indicating that a message failed to send.")
}
} else {
return DateUtil.formatMessageTimestampForCVC(interaction.timestamp,
shouldUseLongFormat: shouldUseLongFormat)
}
}
static func buildState(interaction: TSInteraction, hasTapForMore: Bool) -> State {
let timestampText = Self.timestampText(forInteraction: interaction,
shouldUseLongFormat: false)
var statusIndicator: StatusIndicator?
var accessibilityLabel: String?
if let outgoingMessage = interaction as? TSOutgoingMessage {
let messageStatus = MessageRecipientStatusUtils.recipientStatus(outgoingMessage: outgoingMessage)
accessibilityLabel = MessageRecipientStatusUtils.receiptMessage(outgoingMessage: outgoingMessage)
switch messageStatus {
case .uploading, .sending:
statusIndicator = StatusIndicator(imageName: "message_status_sending",
imageSize: .square(12),
isAnimated: true)
case .pending:
statusIndicator = StatusIndicator(imageName: "message_status_sending",
imageSize: .square(12),
isAnimated: false)
case .sent, .skipped:
statusIndicator = StatusIndicator(imageName: "message_status_sent",
imageSize: .square(12),
isAnimated: false)
case .delivered:
statusIndicator = StatusIndicator(imageName: "message_status_delivered",
imageSize: .init(width: 18, height: 12),
isAnimated: false)
case .read, .viewed:
statusIndicator = StatusIndicator(imageName: "message_status_read",
imageSize: .init(width: 18, height: 12),
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,
hasTapForMore: hasTapForMore,
expiration: expiration)
}
private func timestampLabelConfig(textColor: UIColor) -> CVLabelConfig {
return CVLabelConfig(text: timestampText,
font: .ows_dynamicTypeCaption1,
textColor: textColor)
}
private var tapForMoreLabelConfig: CVLabelConfig? {
guard hasTapForMore, !wasRemotelyDeleted else {
return nil
}
guard let message = interaction as? TSMessage else {
owsFailDebug("Invalid interaction.")
return nil
}
let text = NSLocalizedString("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(text: text,
font: UIFont.ows_dynamicTypeSubheadlineClamped.ows_semibold,
textColor: conversationStyle.bubbleReadMoreTextColor(message: message),
textAlignment: UIView.textAlignmentUnnatural())
}
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)
// 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,
nil != interaction as? TSMessage {
let timerSize = MessageTimerView.measureSize
innerSubviewInfos.append(timerSize.asManualSubviewInfo(hasFixedWidth: true))
}
if let statusIndicator = self.statusIndicator {
let statusSize = statusIndicator.imageSize
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
public override func handleTap(sender: UITapGestureRecognizer,
componentDelegate: CVComponentDelegate,
componentView: CVComponentView,
renderItem: CVRenderItem) -> Bool {
guard hasTapForMore else {
return false
}
let itemViewModel = CVItemViewModelImpl(renderItem: renderItem)
componentDelegate.didTapTruncatedTextMessage(itemViewModel)
return true
}
// 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 timestampLabel = CVLabel()
fileprivate let statusIndicatorImageView = CVImageView()
fileprivate let messageTimerView = MessageTimerView()
fileprivate let chatColorView = CVColorOrGradientView()
public var isDedicatedCellView = false
public var rootView: UIView {
outerStack
}
override required init() {
timestampLabel.textAlignment = UIView.textAlignmentUnnatural()
}
public func setIsCellVisible(_ isCellVisible: Bool) {}
public func reset() {
outerStack.reset()
innerStack.reset()
innerStack.backgroundColor = nil
tapForMoreLabel.text = nil
timestampLabel.text = nil
statusIndicatorImageView.image = nil
statusIndicatorImageView.layer.removeAllAnimations()
messageTimerView.prepareForReuse()
messageTimerView.removeFromSuperview()
chatColorView.reset()
chatColorView.removeFromSuperview()
}
fileprivate func animateSpinningIcon() {
let animation = CABasicAnimation.init(keyPath: "transform.rotation.z")
animation.toValue = CGFloat.pi * 2
animation.duration = kSecondInterval * 1
animation.isCumulative = true
animation.repeatCount = .greatestFiniteMagnitude
statusIndicatorImageView.layer.add(animation, forKey: "animation")
}
}
}