// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalCoreKit import SignalServiceKit import SignalUI 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 let displayEditedLabel: 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 } public var displayEditedLabel: Bool { footerState.displayEditedLabel } 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) } 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 = expiration { let messageTimerView = componentView.messageTimerView messageTimerView.configure( expirationTimestampMs: expiration.expirationTimestamp, disappearingMessageInterval: 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 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 ) -> String { let status = Self.outgoingMessageStatus(interaction: interaction, hasBodyAttachments: hasBodyAttachments) let isPendingOutgoingMessage = status == .pending let isFailedOutgoingMessage = status == .failed let wasSentToAnyRecipient: Bool = { guard let outgoingMessage = interaction as? TSOutgoingMessage else { return false } return outgoingMessage.wasSentToAnyRecipient }() if isPendingOutgoingMessage { return OWSLocalizedString("MESSAGE_STATUS_PENDING", comment: "Label indicating that a message send was paused.") } else if isFailedOutgoingMessage { 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?, hasTapForMore: Bool, transaction: SDSAnyReadTransaction ) -> 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 ) return State( timestampText: timestampText, statusIndicator: nil, accessibilityLabel: nil, hasTapForMore: hasTapForMore, displayEditedLabel: false, 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", 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, displayEditedLabel: false, 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, hasTapForMore: Bool, transaction: SDSAnyReadTransaction) -> State { let hasBodyAttachments = (interaction as? TSMessage)?.hasBodyAttachments(transaction: transaction) ?? false let timestampText = Self.timestampText( forInteraction: interaction, shouldUseLongFormat: false, hasBodyAttachments: hasBodyAttachments ) 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", 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? 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, hasTapForMore: hasTapForMore, displayEditedLabel: displayEditedLabel, 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? { guard hasTapForMore, !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 ) } 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 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, 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: UIGestureRecognizer, componentDelegate: CVComponentDelegate, componentView: CVComponentView, renderItem: CVRenderItem) -> Bool { guard let componentView = componentView as? CVComponentViewFooter else { owsFailDebug("Unexpected componentView.") return false } if hasTapForMore { let readMoreLabel = componentView.tapForMoreLabel let location = sender.location(in: readMoreLabel) if readMoreLabel.bounds.contains(location) { let itemViewModel = CVItemViewModelImpl(renderItem: renderItem) componentDelegate.didTapTruncatedTextMessage(itemViewModel) 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 chatColorView = CVColorOrGradientView() 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() 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") } } }