1775 lines
80 KiB
Swift
1775 lines
80 KiB
Swift
//
|
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
@objc
|
|
public class CVComponentMessage: CVComponentBase, CVRootComponent {
|
|
|
|
public var cellReuseIdentifier: CVCellReuseIdentifier {
|
|
guard !isShowingSelectionUI else {
|
|
return .`default`
|
|
}
|
|
if let bodyTextState = itemViewState.bodyTextState,
|
|
!bodyTextState.canUseDedicatedCell {
|
|
return .`default`
|
|
}
|
|
let dedicatedTextOnlyKeys: [CVComponentKey] = [
|
|
.bodyText
|
|
]
|
|
let activeKeys = activeComponentKeys()
|
|
if activeKeys == Set(dedicatedTextOnlyKeys) {
|
|
return isIncoming ? .dedicatedTextOnlyIncoming : .dedicatedTextOnlyOutgoing
|
|
} else {
|
|
return .`default`
|
|
}
|
|
}
|
|
|
|
public var isDedicatedCell: Bool {
|
|
// TODO: Re-enable reuse of this component.
|
|
// cellReuseIdentifier != .`default`
|
|
return false
|
|
}
|
|
|
|
private var bodyText: CVComponent?
|
|
|
|
private var bodyMedia: CVComponent?
|
|
|
|
private var senderName: CVComponent?
|
|
|
|
private var senderAvatar: CVComponentState.SenderAvatar?
|
|
private var hasSenderAvatarLayout: Bool {
|
|
// Return true if space for a sender avatar appears in the layout.
|
|
// Avatar itself might not appear due to de-duplication.
|
|
isIncoming && isGroupThread && senderAvatar != nil
|
|
}
|
|
private var hasSenderAvatar: Bool {
|
|
// Return true if a sender avatar appears.
|
|
hasSenderAvatarLayout && itemViewState.shouldShowSenderAvatar
|
|
}
|
|
|
|
// This is the "standalone" footer, as opposed to
|
|
// a footer overlaid over body media.
|
|
private var standaloneFooter: CVComponentFooter?
|
|
|
|
private var sticker: CVComponent?
|
|
|
|
private var viewOnce: CVComponent?
|
|
|
|
private var quotedReply: CVComponent?
|
|
|
|
private var linkPreview: CVComponent?
|
|
|
|
private var reactions: CVComponent?
|
|
|
|
private var audioAttachment: CVComponent?
|
|
|
|
private var genericAttachment: CVComponent?
|
|
|
|
private var contactShare: CVComponent?
|
|
|
|
private var bottomButtons: CVComponent?
|
|
|
|
private var swipeToReplyProgress: CVSwipeToReplyState.Progress?
|
|
private var swipeToReplyReference: CVSwipeToReplyState.Reference?
|
|
|
|
private var hasSendFailureBadge = false
|
|
|
|
override init(itemModel: CVItemModel) {
|
|
super.init(itemModel: itemModel)
|
|
|
|
buildComponentStates()
|
|
}
|
|
|
|
private var sharpCorners: OWSDirectionalRectCorner {
|
|
|
|
var rawValue: UInt = 0
|
|
|
|
if !itemViewState.isFirstInCluster {
|
|
rawValue |= isIncoming ? OWSDirectionalRectCorner.topLeading.rawValue : OWSDirectionalRectCorner.topTrailing.rawValue
|
|
}
|
|
|
|
if !itemViewState.isLastInCluster {
|
|
rawValue |= isIncoming ? OWSDirectionalRectCorner.bottomLeading.rawValue : OWSDirectionalRectCorner.bottomTrailing.rawValue
|
|
}
|
|
|
|
return OWSDirectionalRectCorner(rawValue: rawValue)
|
|
}
|
|
|
|
private var sharpCornersForQuotedMessage: OWSDirectionalRectCorner {
|
|
if itemViewState.senderName != nil {
|
|
return .allCorners
|
|
} else {
|
|
var rawValue = sharpCorners.rawValue
|
|
rawValue |= OWSDirectionalRectCorner.bottomLeading.rawValue
|
|
rawValue |= OWSDirectionalRectCorner.bottomTrailing.rawValue
|
|
return OWSDirectionalRectCorner(rawValue: rawValue)
|
|
}
|
|
}
|
|
|
|
private func subcomponent(forKey key: CVComponentKey) -> CVComponent? {
|
|
switch key {
|
|
case .senderName:
|
|
return self.senderName
|
|
case .bodyText:
|
|
return self.bodyText
|
|
case .bodyMedia:
|
|
return self.bodyMedia
|
|
case .footer:
|
|
return self.standaloneFooter
|
|
case .sticker:
|
|
return self.sticker
|
|
case .viewOnce:
|
|
return self.viewOnce
|
|
case .audioAttachment:
|
|
return self.audioAttachment
|
|
case .genericAttachment:
|
|
return self.genericAttachment
|
|
case .quotedReply:
|
|
return self.quotedReply
|
|
case .linkPreview:
|
|
return self.linkPreview
|
|
case .reactions:
|
|
return self.reactions
|
|
case .contactShare:
|
|
return self.contactShare
|
|
case .bottomButtons:
|
|
return self.bottomButtons
|
|
|
|
// We don't render sender avatars with a subcomponent.
|
|
case .senderAvatar:
|
|
return nil
|
|
case .systemMessage, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .failedDownloads, .sendFailureBadge:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private var canFooterOverlayMedia: Bool {
|
|
hasBodyMediaWithThumbnail && !isBorderless
|
|
}
|
|
|
|
private var hasBodyMediaWithThumbnail: Bool {
|
|
bodyMedia != nil
|
|
}
|
|
|
|
// TODO: We might want to render the "remotely deleted" indicator using a dedicated component.
|
|
private var hasBodyText: Bool {
|
|
if wasRemotelyDeleted {
|
|
return true
|
|
}
|
|
|
|
return componentState.bodyText != nil
|
|
}
|
|
|
|
private var isBubbleTransparent: Bool {
|
|
if wasRemotelyDeleted {
|
|
return false
|
|
} else if componentState.isSticker {
|
|
return true
|
|
} else if isBorderlessViewOnceMessage {
|
|
return true
|
|
} else {
|
|
return isBorderless
|
|
}
|
|
}
|
|
|
|
private var isBorderlessViewOnceMessage: Bool {
|
|
guard let viewOnce = componentState.viewOnce else {
|
|
return false
|
|
}
|
|
switch viewOnce.viewOnceState {
|
|
case .unknown:
|
|
owsFailDebug("Invalid value.")
|
|
return true
|
|
case .incomingExpired, .incomingInvalidContent:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private var hasTapForMore: Bool {
|
|
standaloneFooter?.hasTapForMore ?? false
|
|
}
|
|
|
|
private func buildComponentStates() {
|
|
|
|
hasSendFailureBadge = componentState.sendFailureBadge != nil
|
|
|
|
if let senderName = itemViewState.senderName {
|
|
self.senderName = CVComponentSenderName(itemModel: itemModel, senderName: senderName)
|
|
}
|
|
if let senderAvatar = componentState.senderAvatar {
|
|
self.senderAvatar = senderAvatar
|
|
}
|
|
if let stickerState = componentState.sticker {
|
|
self.sticker = CVComponentSticker(itemModel: itemModel, sticker: stickerState)
|
|
}
|
|
if let viewOnceState = componentState.viewOnce {
|
|
self.viewOnce = CVComponentViewOnce(itemModel: itemModel, viewOnce: viewOnceState)
|
|
}
|
|
if let audioAttachmentState = componentState.audioAttachment {
|
|
self.audioAttachment = CVComponentAudioAttachment(itemModel: itemModel,
|
|
audioAttachment: audioAttachmentState)
|
|
}
|
|
if let genericAttachmentState = componentState.genericAttachment {
|
|
self.genericAttachment = CVComponentGenericAttachment(itemModel: itemModel,
|
|
genericAttachment: genericAttachmentState)
|
|
}
|
|
if let bodyTextState = itemViewState.bodyTextState {
|
|
bodyText = CVComponentBodyText(itemModel: itemModel, bodyTextState: bodyTextState)
|
|
}
|
|
if let contactShareState = componentState.contactShare {
|
|
contactShare = CVComponentContactShare(itemModel: itemModel,
|
|
contactShareState: contactShareState)
|
|
}
|
|
if let bottomButtonsState = componentState.bottomButtons {
|
|
bottomButtons = CVComponentBottomButtons(itemModel: itemModel,
|
|
bottomButtonsState: bottomButtonsState)
|
|
}
|
|
|
|
var footerOverlay: CVComponentFooter?
|
|
if let bodyMediaState = componentState.bodyMedia {
|
|
let shouldFooterOverlayMedia = (bodyText == nil && !isBorderless && !itemViewState.shouldHideFooter && !hasTapForMore)
|
|
if shouldFooterOverlayMedia {
|
|
if let footerState = itemViewState.footerState {
|
|
footerOverlay = CVComponentFooter(itemModel: itemModel,
|
|
footerState: footerState,
|
|
isOverlayingMedia: true,
|
|
isOutsideBubble: false)
|
|
} else {
|
|
owsFailDebug("Missing footerState.")
|
|
}
|
|
}
|
|
|
|
bodyMedia = CVComponentBodyMedia(itemModel: itemModel, bodyMedia: bodyMediaState, footerOverlay: footerOverlay)
|
|
}
|
|
|
|
let hasStandaloneFooter = (footerOverlay == nil && !itemViewState.shouldHideFooter)
|
|
if hasStandaloneFooter {
|
|
if let footerState = itemViewState.footerState {
|
|
self.standaloneFooter = CVComponentFooter(itemModel: itemModel,
|
|
footerState: footerState,
|
|
isOverlayingMedia: false,
|
|
isOutsideBubble: isBubbleTransparent)
|
|
} else {
|
|
owsFailDebug("Missing footerState.")
|
|
}
|
|
}
|
|
|
|
if let quotedReplyState = componentState.quotedReply {
|
|
self.quotedReply = CVComponentQuotedReply(itemModel: itemModel,
|
|
quotedReply: quotedReplyState,
|
|
sharpCornersForQuotedMessage: sharpCornersForQuotedMessage)
|
|
}
|
|
|
|
if let linkPreviewState = componentState.linkPreview {
|
|
self.linkPreview = CVComponentLinkPreview(itemModel: itemModel,
|
|
linkPreviewState: linkPreviewState)
|
|
}
|
|
|
|
if let reactionsState = componentState.reactions {
|
|
self.reactions = CVComponentReactions(itemModel: itemModel, reactions: reactionsState)
|
|
}
|
|
}
|
|
|
|
public func configure(cellView: UIView,
|
|
cellMeasurement: CVCellMeasurement,
|
|
componentDelegate: CVComponentDelegate,
|
|
cellSelection: CVCellSelection,
|
|
swipeToReplyState: CVSwipeToReplyState,
|
|
componentView: CVComponentView) {
|
|
|
|
guard let componentView = componentView as? CVComponentViewMessage else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
return
|
|
}
|
|
|
|
configureForRendering(componentView: componentView,
|
|
cellMeasurement: cellMeasurement,
|
|
componentDelegate: componentDelegate)
|
|
let rootView = componentView.rootView
|
|
let isReusing = rootView.superview != nil
|
|
if !isReusing {
|
|
owsAssertDebug(cellView.layoutMargins == .zero)
|
|
owsAssertDebug(cellView.subviews.isEmpty)
|
|
|
|
cellView.layoutMargins = cellLayoutMargins
|
|
cellView.addSubview(rootView)
|
|
rootView.autoPinEdge(toSuperviewEdge: .top)
|
|
}
|
|
let bottomInset = reactions != nil ? reactionsVProtrusion : 0
|
|
componentView.layoutConstraints.append(rootView.autoPinEdge(toSuperviewEdge: .bottom, withInset: bottomInset))
|
|
componentView.layoutConstraints.append(rootView.autoSetDimension(.width, toSize: cellMeasurement.cellSize.width))
|
|
|
|
self.swipeToReplyReference = nil
|
|
self.swipeToReplyProgress = swipeToReplyState.getProgress(interactionId: interaction.uniqueId)
|
|
|
|
var leadingView: UIView?
|
|
if isShowingSelectionUI {
|
|
owsAssertDebug(!isReusing)
|
|
|
|
let selectionView = componentView.selectionView
|
|
selectionView.isSelected = componentDelegate.cvc_isMessageSelected(interaction)
|
|
cellView.addSubview(selectionView)
|
|
selectionView.autoPinEdges(toSuperviewMarginsExcludingEdge: .trailing)
|
|
leadingView = selectionView
|
|
}
|
|
|
|
if isReusing {
|
|
owsAssertDebug(leadingView == nil)
|
|
owsAssertDebug(!hasSendFailureBadge)
|
|
} else if isIncoming {
|
|
if let leadingView = leadingView {
|
|
rootView.autoPinEdge(.leading, to: .trailing, of: leadingView, withOffset: selectionViewSpacing)
|
|
} else {
|
|
rootView.autoPinEdge(toSuperviewMargin: .leading)
|
|
}
|
|
} else if hasSendFailureBadge {
|
|
// Send failures are rare, so it's cheaper to only build these views when we need them.
|
|
let sendFailureBadge = UIImageView()
|
|
sendFailureBadge.setTemplateImageName("error-outline-24", tintColor: .ows_accentRed)
|
|
sendFailureBadge.autoSetDimensions(to: CGSize(square: sendFailureBadgeSize))
|
|
cellView.addSubview(sendFailureBadge)
|
|
sendFailureBadge.autoPinEdge(toSuperviewMargin: .trailing)
|
|
let sendFailureBadgeBottomMargin = round(conversationStyle.lastTextLineAxis - sendFailureBadgeSize * 0.5)
|
|
sendFailureBadge.autoPinEdge(.bottom, to: .bottom, of: rootView, withOffset: -sendFailureBadgeBottomMargin)
|
|
|
|
rootView.autoPinEdge(.trailing, to: .leading, of: sendFailureBadge, withOffset: -sendFailureBadgeSpacing)
|
|
} else {
|
|
rootView.autoPinEdge(toSuperviewMargin: .trailing)
|
|
}
|
|
}
|
|
|
|
public func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView {
|
|
CVComponentViewMessage()
|
|
}
|
|
|
|
public static let textViewVSpacing: CGFloat = 2
|
|
public static let bodyMediaQuotedReplyVSpacing: CGFloat = 6
|
|
public static let quotedReplyTopMargin: CGFloat = 6
|
|
|
|
private var selectionViewSpacing: CGFloat { ConversationStyle.messageStackSpacing }
|
|
private var selectionViewWidth: CGFloat { ConversationStyle.selectionViewWidth }
|
|
private let sendFailureBadgeSize: CGFloat = 24
|
|
private var sendFailureBadgeSpacing: CGFloat { ConversationStyle.messageStackSpacing }
|
|
|
|
// The "message" contents of this component are vertically
|
|
// stacked in four sections. Ordering of the keys in each
|
|
// section determines the ordering of the subcomponents.
|
|
private var topFullWidthCVComponentKeys: [CVComponentKey] { [.linkPreview] }
|
|
private var topNestedCVComponentKeys: [CVComponentKey] { [.senderName] }
|
|
private var bottomFullWidthCVComponentKeys: [CVComponentKey] { [.quotedReply, .bodyMedia] }
|
|
private var bottomNestedCVComponentKeys: [CVComponentKey] { [.viewOnce, .audioAttachment, .genericAttachment, .contactShare, .bodyText, .footer] }
|
|
|
|
public func configureForRendering(componentView: CVComponentView,
|
|
cellMeasurement: CVCellMeasurement,
|
|
componentDelegate: CVComponentDelegate) {
|
|
guard let componentView = componentView as? CVComponentViewMessage else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
return
|
|
}
|
|
|
|
let isReusing = componentView.rootView.superview != nil
|
|
guard !isReusing else {
|
|
// This "dedicated" component already has the correct
|
|
// configuration; we only need to update the contents
|
|
// of the subcomponents.
|
|
let bodyTextResult = configureSubcomponentForStack(messageView: componentView,
|
|
axis: .vertical,
|
|
cellMeasurement: cellMeasurement,
|
|
componentDelegate: componentDelegate,
|
|
key: .bodyText)
|
|
owsAssertDebug(bodyTextResult != nil)
|
|
|
|
_ = configureSubcomponentForStack(messageView: componentView,
|
|
axis: .vertical,
|
|
cellMeasurement: cellMeasurement,
|
|
componentDelegate: componentDelegate,
|
|
key: .footer)
|
|
|
|
return
|
|
}
|
|
|
|
let cellHStack = componentView.cellHStack
|
|
cellHStack.apply(config: cellHStackConfig)
|
|
|
|
var outerAvatarView: AvatarImageView?
|
|
var outerBubbleView: OWSBubbleView?
|
|
let outerContentView: UIView
|
|
|
|
if hasSenderAvatarLayout,
|
|
let senderAvatar = self.senderAvatar {
|
|
if hasSenderAvatar {
|
|
componentView.avatarView.image = senderAvatar.senderAvatar
|
|
}
|
|
outerAvatarView = componentView.avatarView
|
|
}
|
|
|
|
let topFullWidthSubcomponents = subcomponents(forKeys: topFullWidthCVComponentKeys)
|
|
let topNestedSubcomponents = subcomponents(forKeys: topNestedCVComponentKeys)
|
|
let bottomFullWidthSubcomponents = subcomponents(forKeys: bottomFullWidthCVComponentKeys)
|
|
let bottomNestedSubcomponents = subcomponents(forKeys: bottomNestedCVComponentKeys)
|
|
let stickerOverlaySubcomponent = subcomponent(forKey: .sticker)
|
|
|
|
func configureBubbleView() {
|
|
let bubbleView = componentView.bubbleView
|
|
bubbleView.backgroundColor = bubbleBackgroundColor
|
|
bubbleView.sharpCorners = self.sharpCorners
|
|
if let bubbleStrokeColor = self.bubbleStrokeColor {
|
|
bubbleView.strokeColor = bubbleStrokeColor
|
|
bubbleView.strokeThickness = 1
|
|
} else {
|
|
bubbleView.strokeColor = nil
|
|
bubbleView.strokeThickness = 0
|
|
}
|
|
outerBubbleView = bubbleView
|
|
}
|
|
|
|
func configureStackView(_ stackView: OWSStackView,
|
|
config: CVStackViewConfig,
|
|
componentKeys keys: [CVComponentKey]) -> OWSStackView {
|
|
self.configureSubcomponentStack(messageView: componentView,
|
|
stackView: stackView,
|
|
config: config,
|
|
cellMeasurement: cellMeasurement,
|
|
componentDelegate: componentDelegate,
|
|
keys: keys)
|
|
return stackView
|
|
}
|
|
|
|
if nil != stickerOverlaySubcomponent {
|
|
// Sticker message.
|
|
//
|
|
// Stack is borderless.
|
|
//
|
|
// Optional senderName and footer.
|
|
outerContentView = configureStackView(componentView.contentStackView,
|
|
config: buildBorderlessStackViewConfig(),
|
|
componentKeys: [.senderName, .sticker, .footer])
|
|
} else {
|
|
// Has full-width components.
|
|
|
|
// TODO: We don't always use the bubble view for media.
|
|
configureBubbleView()
|
|
|
|
let contentStackView = componentView.contentStackView
|
|
contentStackView.apply(config: buildNoMarginsStackViewConfig())
|
|
outerContentView = contentStackView
|
|
|
|
if !topFullWidthSubcomponents.isEmpty {
|
|
let config = buildFullWidthStackViewConfig(includeTopMargin: false)
|
|
let topFullWidthStackView = configureStackView(componentView.topFullWidthStackView,
|
|
config: config,
|
|
componentKeys: topFullWidthCVComponentKeys)
|
|
contentStackView.addArrangedSubview(topFullWidthStackView)
|
|
}
|
|
if !topNestedSubcomponents.isEmpty {
|
|
let hasNeighborsAbove = !topFullWidthSubcomponents.isEmpty
|
|
let hasNeighborsBelow = (!bottomFullWidthSubcomponents.isEmpty ||
|
|
!bottomNestedSubcomponents.isEmpty ||
|
|
nil != bottomButtons)
|
|
let config = buildNestedStackViewConfig(hasNeighborsAbove: hasNeighborsAbove,
|
|
hasNeighborsBelow: hasNeighborsBelow)
|
|
let topNestedStackView = configureStackView(componentView.topNestedStackView,
|
|
config: config,
|
|
componentKeys: topNestedCVComponentKeys)
|
|
contentStackView.addArrangedSubview(topNestedStackView)
|
|
}
|
|
if !bottomFullWidthSubcomponents.isEmpty {
|
|
// If a quoted reply is the top-most subcomponent,
|
|
// apply a top margin.
|
|
let applyTopMarginToFullWidthStack = (topFullWidthSubcomponents.isEmpty &&
|
|
topNestedSubcomponents.isEmpty &&
|
|
quotedReply != nil)
|
|
let config = buildFullWidthStackViewConfig(includeTopMargin: applyTopMarginToFullWidthStack)
|
|
let bottomFullWidthStackView = configureStackView(componentView.bottomFullWidthStackView,
|
|
config: config,
|
|
componentKeys: bottomFullWidthCVComponentKeys)
|
|
contentStackView.addArrangedSubview(bottomFullWidthStackView)
|
|
}
|
|
if !bottomNestedSubcomponents.isEmpty {
|
|
let hasNeighborsAbove = (!topFullWidthSubcomponents.isEmpty ||
|
|
!topNestedSubcomponents.isEmpty ||
|
|
!bottomFullWidthSubcomponents.isEmpty)
|
|
let hasNeighborsBelow = (nil != bottomButtons)
|
|
let config = buildNestedStackViewConfig(hasNeighborsAbove: hasNeighborsAbove,
|
|
hasNeighborsBelow: hasNeighborsBelow)
|
|
let bottomNestedStackView = configureStackView(componentView.bottomNestedStackView,
|
|
config: config,
|
|
componentKeys: bottomNestedCVComponentKeys)
|
|
contentStackView.addArrangedSubview(bottomNestedStackView)
|
|
}
|
|
if nil != bottomButtons {
|
|
if let componentAndView = configureSubcomponentForStack(messageView: componentView,
|
|
axis: .vertical,
|
|
cellMeasurement: cellMeasurement,
|
|
componentDelegate: componentDelegate,
|
|
key: .bottomButtons) {
|
|
let subview = componentAndView.componentView.rootView
|
|
contentStackView.addArrangedSubview(subview)
|
|
} else {
|
|
owsFailDebug("Couldn't configure bottomButtons.")
|
|
}
|
|
}
|
|
}
|
|
|
|
if let contentWidth = cellMeasurement.value(key: contentWidthKey) {
|
|
componentView.layoutConstraints.append(outerContentView.autoSetDimension(.width, toSize: contentWidth))
|
|
} else {
|
|
owsFailDebug("Missing contentWidth.")
|
|
}
|
|
|
|
if let subview = outerAvatarView {
|
|
subview.setContentHuggingHigh()
|
|
subview.setCompressionResistanceHigh()
|
|
cellHStack.addArrangedSubview(subview)
|
|
}
|
|
|
|
let swipeToReplyContentView: UIView
|
|
if let bubbleView = outerBubbleView {
|
|
outerContentView.setContentHuggingLow()
|
|
outerContentView.setCompressionResistanceLow()
|
|
bubbleView.addSubview(outerContentView)
|
|
outerContentView.autoPinEdgesToSuperviewEdges()
|
|
bubbleView.setContentHuggingLow()
|
|
bubbleView.setCompressionResistanceLow()
|
|
cellHStack.addArrangedSubview(bubbleView)
|
|
swipeToReplyContentView = bubbleView
|
|
|
|
if let componentAndView = findActiveComponentAndView(key: .bodyMedia,
|
|
messageView: componentView) {
|
|
if let bodyMediaComponent = componentAndView.component as? CVComponentBodyMedia {
|
|
if let bubbleViewPartner = bodyMediaComponent.bubbleViewPartner(componentView: componentAndView.componentView) {
|
|
bubbleView.addPartnerView(bubbleViewPartner)
|
|
}
|
|
} else {
|
|
owsFailDebug("Invalid component.")
|
|
}
|
|
}
|
|
} else {
|
|
outerContentView.setContentHuggingLow()
|
|
outerContentView.setCompressionResistanceLow()
|
|
cellHStack.addArrangedSubview(outerContentView)
|
|
swipeToReplyContentView = outerContentView
|
|
}
|
|
|
|
componentView.swipeToReplyContentView = swipeToReplyContentView
|
|
let swipeToReplyIconView = componentView.swipeToReplyIconView
|
|
swipeToReplyIconView.setTemplateImageName("reply-outline-24",
|
|
tintColor: .ows_gray45)
|
|
swipeToReplyIconView.contentMode = .scaleAspectFit
|
|
swipeToReplyIconView.alpha = 0
|
|
cellHStack.addSubview(swipeToReplyIconView)
|
|
cellHStack.sendSubviewToBack(swipeToReplyIconView)
|
|
swipeToReplyIconView.autoAlignAxis(.horizontal, toSameAxisOf: swipeToReplyContentView)
|
|
swipeToReplyIconView.autoPinEdge(.leading, to: .leading, of: swipeToReplyContentView, withOffset: 8)
|
|
|
|
if let reactions = self.reactions {
|
|
let reactionsView = configureSubcomponentView(messageView: componentView,
|
|
subcomponent: reactions,
|
|
cellMeasurement: cellMeasurement,
|
|
componentDelegate: componentDelegate,
|
|
key: .reactions)
|
|
|
|
cellHStack.addSubview(reactionsView.rootView)
|
|
if isIncoming {
|
|
reactionsView.rootView.autoPinEdge(.leading,
|
|
to: .leading,
|
|
of: outerContentView,
|
|
withOffset: +reactionsHInset,
|
|
relation: .greaterThanOrEqual)
|
|
} else {
|
|
reactionsView.rootView.autoPinEdge(.trailing,
|
|
to: .trailing,
|
|
of: outerContentView,
|
|
withOffset: -reactionsHInset,
|
|
relation: .lessThanOrEqual)
|
|
}
|
|
|
|
// We want the reaction bubbles to stick to the middle of the screen inset from
|
|
// the edge of the bubble with a small amount of padding unless the bubble is smaller
|
|
// than the reactions view in which case it will break these constraints and extend
|
|
// further into the middle of the screen than the message itself.
|
|
NSLayoutConstraint.autoSetPriority(.defaultLow) {
|
|
// [NSLayoutConstraint autoSetPriority:UILayoutPriorityDefaultLow
|
|
// forConstraints:^{
|
|
if self.isIncoming {
|
|
reactionsView.rootView.autoPinEdge(.trailing,
|
|
to: .trailing,
|
|
of: outerContentView,
|
|
withOffset: -reactionsHInset)
|
|
} else {
|
|
reactionsView.rootView.autoPinEdge(.leading,
|
|
to: .leading,
|
|
of: outerContentView,
|
|
withOffset: +reactionsHInset)
|
|
}
|
|
}
|
|
|
|
reactionsView.rootView.autoPinEdge(.top,
|
|
to: .bottom,
|
|
of: outerContentView,
|
|
withOffset: -reactionsVOverlap)
|
|
}
|
|
|
|
for componentAndView in activeComponentAndViews(messageView: componentView) {
|
|
let subcomponent = componentAndView.component
|
|
let subcomponentView = componentAndView.componentView
|
|
guard let incompleteAttachmentInfo = subcomponent.incompleteAttachmentInfo(componentView: subcomponentView) else {
|
|
continue
|
|
}
|
|
let attachment = incompleteAttachmentInfo.attachment
|
|
let attachmentView = incompleteAttachmentInfo.attachmentView
|
|
let shouldShowDownloadProgress = incompleteAttachmentInfo.shouldShowDownloadProgress
|
|
guard let progressViewToken = addProgressViewsIfNecessary(attachment: attachment,
|
|
attachmentView: attachmentView,
|
|
hostView: outerContentView,
|
|
shouldShowDownloadProgress: shouldShowDownloadProgress) else {
|
|
owsFailDebug("Could not add progress view(s).")
|
|
continue
|
|
}
|
|
componentView.progressViewTokens.append(progressViewToken)
|
|
}
|
|
}
|
|
|
|
private var cellLayoutMargins: UIEdgeInsets {
|
|
UIEdgeInsets(top: 0,
|
|
leading: conversationStyle.fullWidthGutterLeading,
|
|
bottom: 0,
|
|
trailing: conversationStyle.fullWidthGutterTrailing)
|
|
}
|
|
|
|
private var cellHStackConfig: CVStackViewConfig {
|
|
CVStackViewConfig(axis: .horizontal,
|
|
alignment: .bottom,
|
|
spacing: ConversationStyle.messageStackSpacing,
|
|
layoutMargins: .zero)
|
|
}
|
|
|
|
private let reactionsHInset: CGFloat = 6
|
|
// The overlap between the message content and the reactions bubble.
|
|
private var reactionsVOverlap: CGFloat {
|
|
CVReactionCountsView.inset
|
|
}
|
|
// How far the reactions bubble protrudes below the message content.
|
|
private var reactionsVProtrusion: CGFloat {
|
|
let reactionsHeight = CVReactionCountsView.height
|
|
return max(0, reactionsHeight - reactionsVOverlap)
|
|
}
|
|
|
|
private var bubbleBackgroundColor: UIColor {
|
|
if wasRemotelyDeleted {
|
|
return Theme.backgroundColor
|
|
}
|
|
if isBubbleTransparent {
|
|
return .clear
|
|
}
|
|
return itemModel.conversationStyle.bubbleColor(isIncoming: isIncoming)
|
|
}
|
|
|
|
private var bubbleStrokeColor: UIColor? {
|
|
if wasRemotelyDeleted || isBorderlessViewOnceMessage {
|
|
return Theme.outlineColor
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func measureStackView(config: CVStackViewConfig,
|
|
measurementBuilder: CVCellMeasurement.Builder,
|
|
componentKeys keys: [CVComponentKey],
|
|
maxWidth: CGFloat) -> CGSize {
|
|
|
|
let maxWidth = maxWidth - config.layoutMargins.left + config.layoutMargins.right
|
|
var subviewSizes = [CGSize]()
|
|
for key in keys {
|
|
guard let subcomponent = self.subcomponent(forKey: key) else {
|
|
// Not all subcomponents may be present.
|
|
continue
|
|
}
|
|
let subviewSize = subcomponent.measure(maxWidth: maxWidth, measurementBuilder: measurementBuilder)
|
|
subviewSizes.append(subviewSize)
|
|
|
|
// We store the measured size so that we can pin the
|
|
// subcomponent view during configuration for rendering.
|
|
measurementBuilder.setSize(key: key.asKey, size: subviewSize)
|
|
}
|
|
|
|
return CVStackView.measure(config: config, subviewSizes: subviewSizes)
|
|
}
|
|
|
|
private let contentWidthKey = "contentWidthKey"
|
|
|
|
public func measure(maxWidth maxWidthChatHistory: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
|
|
owsAssertDebug(maxWidthChatHistory > 0)
|
|
|
|
let outerStackViewMaxWidth = max(0, maxWidthChatHistory - cellLayoutMargins.totalWidth)
|
|
var cellHStackSize = CGSize.zero
|
|
var outerStackViewSize: CGSize = .zero
|
|
|
|
if hasSenderAvatarLayout {
|
|
// Sender avatar in groups.
|
|
outerStackViewSize.width += ConversationStyle.groupMessageAvatarDiameter + ConversationStyle.messageStackSpacing
|
|
outerStackViewSize.height = max(outerStackViewSize.height, ConversationStyle.groupMessageAvatarDiameter)
|
|
}
|
|
if isShowingSelectionUI {
|
|
outerStackViewSize.width += selectionViewWidth + selectionViewSpacing
|
|
}
|
|
if !isIncoming, hasSendFailureBadge {
|
|
outerStackViewSize.width += sendFailureBadgeSize + sendFailureBadgeSpacing
|
|
}
|
|
// The message cell's "outer" stack can contain many views:
|
|
// sender avatar, selection UI, send failure badge.
|
|
// The message cell's "content" stack must fit within the
|
|
// remaining space in the "outer" stack.
|
|
let contentMaxWidth = max(0,
|
|
min(conversationStyle.maxMessageWidth,
|
|
outerStackViewMaxWidth - outerStackViewSize.width))
|
|
|
|
func measureStackView(config: CVStackViewConfig,
|
|
componentKeys keys: [CVComponentKey]) -> CGSize {
|
|
let maxStackWidth = max(0, contentMaxWidth - config.layoutMargins.totalWidth)
|
|
return self.measureStackView(config: config,
|
|
measurementBuilder: measurementBuilder,
|
|
componentKeys: keys,
|
|
maxWidth: maxStackWidth)
|
|
}
|
|
|
|
let topFullWidthSubcomponents = subcomponents(forKeys: topFullWidthCVComponentKeys)
|
|
let topNestedSubcomponents = subcomponents(forKeys: topNestedCVComponentKeys)
|
|
let bottomFullWidthSubcomponents = subcomponents(forKeys: bottomFullWidthCVComponentKeys)
|
|
let bottomNestedSubcomponents = subcomponents(forKeys: bottomNestedCVComponentKeys)
|
|
let stickerOverlaySubcomponent = subcomponent(forKey: .sticker)
|
|
|
|
func applyContentMeasurement(_ size: CGSize) {
|
|
outerStackViewSize.width += size.width
|
|
outerStackViewSize.height = max(outerStackViewSize.height, size.height)
|
|
|
|
measurementBuilder.setValue(key: contentWidthKey, value: size.width)
|
|
}
|
|
|
|
if nil != stickerOverlaySubcomponent {
|
|
// Sticker message.
|
|
//
|
|
// Stack is borderless.
|
|
// Optional footer.
|
|
|
|
applyContentMeasurement(measureStackView(config: buildBorderlessStackViewConfig(),
|
|
componentKeys: [.senderName, .sticker, .footer]))
|
|
} else {
|
|
// There are full-width components.
|
|
// Use multiple stacks.
|
|
|
|
var subviewSizes = [CGSize]()
|
|
|
|
if !topFullWidthSubcomponents.isEmpty {
|
|
let config = buildFullWidthStackViewConfig(includeTopMargin: false)
|
|
subviewSizes.append(measureStackView(config: config,
|
|
componentKeys: topFullWidthCVComponentKeys))
|
|
}
|
|
if !topNestedSubcomponents.isEmpty {
|
|
let hasNeighborsAbove = !topFullWidthSubcomponents.isEmpty
|
|
let hasNeighborsBelow = (!bottomFullWidthSubcomponents.isEmpty ||
|
|
!bottomNestedSubcomponents.isEmpty ||
|
|
nil != bottomButtons)
|
|
let config = buildNestedStackViewConfig(hasNeighborsAbove: hasNeighborsAbove,
|
|
hasNeighborsBelow: hasNeighborsBelow)
|
|
subviewSizes.append(measureStackView(config: config,
|
|
componentKeys: topNestedCVComponentKeys))
|
|
}
|
|
if !bottomFullWidthSubcomponents.isEmpty {
|
|
// If a quoted reply is the top-most subcomponent,
|
|
// apply a top margin.
|
|
let applyTopMarginToFullWidthStack = (topFullWidthSubcomponents.isEmpty &&
|
|
topNestedSubcomponents.isEmpty &&
|
|
quotedReply != nil)
|
|
let config = buildFullWidthStackViewConfig(includeTopMargin: applyTopMarginToFullWidthStack)
|
|
subviewSizes.append(measureStackView(config: config,
|
|
componentKeys: bottomFullWidthCVComponentKeys))
|
|
}
|
|
if !bottomNestedSubcomponents.isEmpty {
|
|
let hasNeighborsAbove = (!topFullWidthSubcomponents.isEmpty ||
|
|
!topNestedSubcomponents.isEmpty ||
|
|
!bottomFullWidthSubcomponents.isEmpty)
|
|
let hasNeighborsBelow = (nil != bottomButtons)
|
|
let config = buildNestedStackViewConfig(hasNeighborsAbove: hasNeighborsAbove,
|
|
hasNeighborsBelow: hasNeighborsBelow)
|
|
subviewSizes.append(measureStackView(config: config,
|
|
componentKeys: bottomNestedCVComponentKeys))
|
|
}
|
|
if let bottomButtons = bottomButtons {
|
|
let subviewSize = bottomButtons.measure(maxWidth: contentMaxWidth,
|
|
measurementBuilder: measurementBuilder)
|
|
|
|
// We store the measured size so that we can pin the
|
|
// subcomponent view during configuration for rendering.
|
|
let key = CVComponentKey.bottomButtons
|
|
measurementBuilder.setSize(key: key.asKey, size: subviewSize)
|
|
|
|
subviewSizes.append(subviewSize)
|
|
}
|
|
|
|
applyContentMeasurement(CVStackView.measure(config: buildNoMarginsStackViewConfig(),
|
|
subviewSizes: subviewSizes))
|
|
}
|
|
|
|
cellHStackSize.width += outerStackViewSize.width
|
|
let minBubbleWidth = kOWSMessageCellCornerRadius_Large * 2
|
|
cellHStackSize.width = max(cellHStackSize.width, minBubbleWidth)
|
|
cellHStackSize.height = max(cellHStackSize.height, outerStackViewSize.height)
|
|
|
|
if nil != reactions {
|
|
cellHStackSize.height += reactionsVProtrusion
|
|
}
|
|
|
|
return cellHStackSize.ceil
|
|
}
|
|
|
|
// MARK: - Events
|
|
|
|
public override func handleTap(sender: UITapGestureRecognizer,
|
|
componentDelegate: CVComponentDelegate,
|
|
componentView: CVComponentView,
|
|
renderItem: CVRenderItem) -> Bool {
|
|
|
|
guard let componentView = componentView as? CVComponentViewMessage else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
return false
|
|
}
|
|
|
|
if isShowingSelectionUI {
|
|
let selectionView = componentView.selectionView
|
|
let itemViewModel = CVItemViewModelImpl(renderItem: renderItem)
|
|
if componentDelegate.cvc_isMessageSelected(interaction) {
|
|
selectionView.isSelected = false
|
|
componentDelegate.cvc_didDeselectViewItem(itemViewModel)
|
|
} else {
|
|
selectionView.isSelected = true
|
|
componentDelegate.cvc_didSelectViewItem(itemViewModel)
|
|
}
|
|
// Suppress other tap handling during selection mode.
|
|
return true
|
|
}
|
|
|
|
if let outgoingMessage = interaction as? TSOutgoingMessage {
|
|
switch outgoingMessage.messageState {
|
|
case .failed:
|
|
// Tap to retry.
|
|
componentDelegate.cvc_didTapFailedOutgoingMessage(outgoingMessage)
|
|
return true
|
|
case .sending:
|
|
// Ignore taps on outgoing messages being sent.
|
|
return true
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
if hasSenderAvatar,
|
|
componentView.avatarView.containsGestureLocation(sender) {
|
|
componentDelegate.cvc_didTapSenderAvatar(interaction)
|
|
return true
|
|
}
|
|
|
|
if let subcomponentAndView = findComponentAndView(sender: sender,
|
|
componentView: componentView) {
|
|
let subcomponent = subcomponentAndView.component
|
|
let subcomponentView = subcomponentAndView.componentView
|
|
Logger.verbose("key: \(subcomponentAndView.key)")
|
|
if subcomponent.handleTap(sender: sender,
|
|
componentDelegate: componentDelegate,
|
|
componentView: subcomponentView,
|
|
renderItem: renderItem) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
if let message = interaction as? TSMessage,
|
|
nil != componentState.failedDownloads {
|
|
Logger.verbose("Retrying failed downloads.")
|
|
componentDelegate.cvc_didTapFailedDownloads(message)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
public override func findLongPressHandler(sender: UILongPressGestureRecognizer,
|
|
componentDelegate: CVComponentDelegate,
|
|
componentView: CVComponentView,
|
|
renderItem: CVRenderItem) -> CVLongPressHandler? {
|
|
|
|
guard let componentView = componentView as? CVComponentViewMessage else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
return nil
|
|
}
|
|
|
|
if let subcomponentView = componentView.subcomponentView(key: .sticker),
|
|
subcomponentView.rootView.containsGestureLocation(sender) {
|
|
return CVLongPressHandler(delegate: componentDelegate,
|
|
renderItem: renderItem,
|
|
gestureLocation: .sticker)
|
|
}
|
|
if let subcomponentView = componentView.subcomponentView(key: .bodyMedia),
|
|
subcomponentView.rootView.containsGestureLocation(sender) {
|
|
return CVLongPressHandler(delegate: componentDelegate,
|
|
renderItem: renderItem,
|
|
gestureLocation: .media)
|
|
}
|
|
if let subcomponentView = componentView.subcomponentView(key: .audioAttachment),
|
|
subcomponentView.rootView.containsGestureLocation(sender) {
|
|
return CVLongPressHandler(delegate: componentDelegate,
|
|
renderItem: renderItem,
|
|
gestureLocation: .media)
|
|
}
|
|
if let subcomponentView = componentView.subcomponentView(key: .genericAttachment),
|
|
subcomponentView.rootView.containsGestureLocation(sender) {
|
|
return CVLongPressHandler(delegate: componentDelegate,
|
|
renderItem: renderItem,
|
|
gestureLocation: .media)
|
|
}
|
|
// TODO: linkPreview?
|
|
if let subcomponentView = componentView.subcomponentView(key: .quotedReply),
|
|
subcomponentView.rootView.containsGestureLocation(sender) {
|
|
return CVLongPressHandler(delegate: componentDelegate,
|
|
renderItem: renderItem,
|
|
gestureLocation: .quotedReply)
|
|
}
|
|
|
|
return CVLongPressHandler(delegate: componentDelegate,
|
|
renderItem: renderItem,
|
|
gestureLocation: .`default`)
|
|
}
|
|
|
|
// For a configured & active cell, this will return the list of
|
|
// currently active subcomponents & their corresponding subcomponent
|
|
// views. This can be used for gesture dispatch, etc.
|
|
private func findComponentAndView(sender: UIGestureRecognizer,
|
|
componentView: CVComponentViewMessage) -> CVComponentAndView? {
|
|
for subcomponentAndView in activeComponentAndViews(messageView: componentView) {
|
|
let subcomponentView = subcomponentAndView.componentView
|
|
let rootView = subcomponentView.rootView
|
|
if rootView.containsGestureLocation(sender) {
|
|
return subcomponentAndView
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// For a configured & active cell, this will return the list of
|
|
// currently active subcomponents & their corresponding subcomponent
|
|
// views. This can be used for gesture dispatch, etc.
|
|
private func activeComponentAndViews(messageView: CVComponentViewMessage) -> [CVComponentAndView] {
|
|
var result = [CVComponentAndView]()
|
|
for key in CVComponentKey.allCases {
|
|
guard let componentAndView = findActiveComponentAndView(key: key,
|
|
messageView: messageView,
|
|
ignoreMissing: true) else {
|
|
continue
|
|
}
|
|
result.append(componentAndView)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// For a configured & active cell, this will return a (component,
|
|
// component view) tuple IFF that component is active.
|
|
private func findActiveComponentAndView(key: CVComponentKey,
|
|
messageView: CVComponentViewMessage,
|
|
ignoreMissing: Bool = false) -> CVComponentAndView? {
|
|
guard let subcomponent = self.subcomponent(forKey: key) else {
|
|
// Not all subcomponents will be active.
|
|
return nil
|
|
}
|
|
guard let subcomponentView = messageView.subcomponentView(key: key) else {
|
|
if ignoreMissing {
|
|
Logger.verbose("Missing subcomponentView: \(key).")
|
|
} else {
|
|
owsFailDebug("Missing subcomponentView.")
|
|
}
|
|
return nil
|
|
}
|
|
return CVComponentAndView(key: key, component: subcomponent, componentView: subcomponentView)
|
|
}
|
|
|
|
private func activeComponentKeys() -> Set<CVComponentKey> {
|
|
Set(CVComponentKey.allCases.filter { key in
|
|
nil != subcomponent(forKey: key)
|
|
})
|
|
}
|
|
|
|
public func albumItemView(forAttachment attachment: TSAttachmentStream,
|
|
componentView: CVComponentView) -> UIView? {
|
|
guard let componentView = componentView as? CVComponentViewMessage else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
return nil
|
|
}
|
|
guard let componentAndView = findActiveComponentAndView(key: .bodyMedia,
|
|
messageView: componentView) else {
|
|
owsFailDebug("Missing bodyMedia subcomponent.")
|
|
return nil
|
|
}
|
|
guard let bodyMediaComponent = componentAndView.component as? CVComponentBodyMedia else {
|
|
owsFailDebug("Unexpected subcomponent.")
|
|
return nil
|
|
}
|
|
let bodyMediaComponentView = componentAndView.componentView
|
|
return bodyMediaComponent.albumItemView(forAttachment: attachment,
|
|
componentView: bodyMediaComponentView)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
// Used for rendering some portion of an Conversation View item.
|
|
// It could be the entire item or some part thereof.
|
|
@objc
|
|
public class CVComponentViewMessage: NSObject, CVComponentView {
|
|
|
|
// Contains the "outer" contents which are arranged horizontally:
|
|
//
|
|
// * Gutters
|
|
// * Group sender bubble
|
|
// * Content view wrapped in message bubble _or_ unwrapped content view.
|
|
// * Reactions view, which uses a custom layout block.
|
|
fileprivate let cellHStack = OWSStackView(name: "cellHStack")
|
|
|
|
fileprivate let avatarView = AvatarImageView()
|
|
|
|
fileprivate let bubbleView = OWSBubbleView()
|
|
fileprivate let contentStackView = OWSStackView(name: "contentStackView")
|
|
|
|
// We use these stack views when there is a mixture of subcomponents,
|
|
// some of which are full-width and some of which are not.
|
|
fileprivate let topFullWidthStackView = OWSStackView(name: "topFullWidthStackView")
|
|
fileprivate let topNestedStackView = OWSStackView(name: "topNestedStackView")
|
|
fileprivate let bottomFullWidthStackView = OWSStackView(name: "bottomFullWidthStackView")
|
|
fileprivate let bottomNestedStackView = OWSStackView(name: "bottomNestedStackView")
|
|
|
|
fileprivate let selectionView = MessageSelectionView()
|
|
|
|
fileprivate var swipeToReplyContentView: UIView?
|
|
|
|
fileprivate let swipeToReplyIconView = UIImageView()
|
|
|
|
fileprivate var layoutConstraints = [NSLayoutConstraint]()
|
|
|
|
fileprivate var progressViewTokens = [ProgressViewToken]()
|
|
|
|
public var isDedicatedCellView = false
|
|
|
|
public var rootView: UIView {
|
|
cellHStack
|
|
}
|
|
|
|
// MARK: - Subcomponents
|
|
|
|
var senderNameView: CVComponentView?
|
|
var bodyTextView: CVComponentView?
|
|
var bodyMediaView: CVComponentView?
|
|
var footerView: CVComponentView?
|
|
var stickerView: CVComponentView?
|
|
var viewOnceView: CVComponentView?
|
|
var quotedReplyView: CVComponentView?
|
|
var linkPreviewView: CVComponentView?
|
|
var reactionsView: CVComponentView?
|
|
var audioAttachmentView: CVComponentView?
|
|
var genericAttachmentView: CVComponentView?
|
|
var contactShareView: CVComponentView?
|
|
var bottomButtonsView: CVComponentView?
|
|
|
|
private var allSubcomponentViews: [CVComponentView] {
|
|
[senderNameView, bodyTextView, bodyMediaView, footerView, stickerView, quotedReplyView, linkPreviewView, reactionsView, viewOnceView, audioAttachmentView, genericAttachmentView, contactShareView, bottomButtonsView].compactMap { $0 }
|
|
}
|
|
|
|
fileprivate func subcomponentView(key: CVComponentKey) -> CVComponentView? {
|
|
switch key {
|
|
case .senderName:
|
|
return senderNameView
|
|
case .bodyText:
|
|
return bodyTextView
|
|
case .bodyMedia:
|
|
return bodyMediaView
|
|
case .footer:
|
|
return footerView
|
|
case .sticker:
|
|
return stickerView
|
|
case .viewOnce:
|
|
return viewOnceView
|
|
case .quotedReply:
|
|
return quotedReplyView
|
|
case .linkPreview:
|
|
return linkPreviewView
|
|
case .reactions:
|
|
return reactionsView
|
|
case .audioAttachment:
|
|
return audioAttachmentView
|
|
case .genericAttachment:
|
|
return genericAttachmentView
|
|
case .contactShare:
|
|
return contactShareView
|
|
case .bottomButtons:
|
|
return bottomButtonsView
|
|
|
|
// We don't render sender avatars with a subcomponent.
|
|
case .senderAvatar:
|
|
owsFailDebug("Invalid component key: \(key)")
|
|
return nil
|
|
case .systemMessage, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .failedDownloads, .sendFailureBadge:
|
|
owsFailDebug("Invalid component key: \(key)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
fileprivate func setSubcomponentView(key: CVComponentKey, subcomponentView: CVComponentView?) {
|
|
switch key {
|
|
case .senderName:
|
|
senderNameView = subcomponentView
|
|
case .bodyText:
|
|
bodyTextView = subcomponentView
|
|
case .bodyMedia:
|
|
bodyMediaView = subcomponentView
|
|
case .footer:
|
|
footerView = subcomponentView
|
|
case .sticker:
|
|
stickerView = subcomponentView
|
|
case .viewOnce:
|
|
viewOnceView = subcomponentView
|
|
case .quotedReply:
|
|
quotedReplyView = subcomponentView
|
|
case .linkPreview:
|
|
linkPreviewView = subcomponentView
|
|
case .reactions:
|
|
reactionsView = subcomponentView
|
|
case .audioAttachment:
|
|
audioAttachmentView = subcomponentView
|
|
case .genericAttachment:
|
|
genericAttachmentView = subcomponentView
|
|
case .contactShare:
|
|
contactShareView = subcomponentView
|
|
case .bottomButtons:
|
|
bottomButtonsView = subcomponentView
|
|
|
|
// We don't render sender avatars with a subcomponent.
|
|
case .senderAvatar:
|
|
owsAssertDebug(subcomponentView == nil)
|
|
case .systemMessage, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .failedDownloads, .sendFailureBadge:
|
|
owsAssertDebug(subcomponentView == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
override required init() {
|
|
avatarView.autoSetDimensions(to: CGSize(square: ConversationStyle.groupMessageAvatarDiameter))
|
|
|
|
bubbleView.layoutMargins = .zero
|
|
}
|
|
|
|
public func setIsCellVisible(_ isCellVisible: Bool) {
|
|
for subcomponentView in allSubcomponentViews {
|
|
subcomponentView.setIsCellVisible(isCellVisible)
|
|
}
|
|
}
|
|
|
|
public func reset() {
|
|
removeSwipeToReplyAnimations()
|
|
|
|
if !isDedicatedCellView {
|
|
cellHStack.reset()
|
|
bubbleView.removeAllSubviews()
|
|
contentStackView.reset()
|
|
topFullWidthStackView.reset()
|
|
topNestedStackView.reset()
|
|
bottomFullWidthStackView.reset()
|
|
bottomNestedStackView.reset()
|
|
}
|
|
|
|
avatarView.image = nil
|
|
|
|
bubbleView.clearPartnerViews()
|
|
|
|
if !isDedicatedCellView {
|
|
swipeToReplyContentView = nil
|
|
swipeToReplyIconView.image = nil
|
|
}
|
|
swipeToReplyIconView.alpha = 0
|
|
|
|
// We use cellHStack.frame to detect whether or not
|
|
// the cell has been laid out yet. Therefore we clear it here.
|
|
cellHStack.frame = .zero
|
|
|
|
if isDedicatedCellView {
|
|
for subcomponentView in allSubcomponentViews {
|
|
subcomponentView.isDedicatedCellView = true
|
|
}
|
|
}
|
|
|
|
for subcomponentView in allSubcomponentViews {
|
|
subcomponentView.reset()
|
|
}
|
|
|
|
if !isDedicatedCellView {
|
|
for key in CVComponentKey.allCases {
|
|
// Don't clear bodyTextView; it is expensive to build.
|
|
if key != .bodyText {
|
|
self.setSubcomponentView(key: key, subcomponentView: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
NSLayoutConstraint.deactivate(layoutConstraints)
|
|
layoutConstraints = []
|
|
|
|
for progressViewToken in progressViewTokens {
|
|
progressViewToken.reset()
|
|
}
|
|
progressViewTokens = []
|
|
}
|
|
|
|
fileprivate func removeSwipeToReplyAnimations() {
|
|
swipeToReplyContentView?.layer.removeAllAnimations()
|
|
avatarView.layer.removeAllAnimations()
|
|
swipeToReplyIconView.layer.removeAllAnimations()
|
|
reactionsView?.rootView.layer.removeAllAnimations()
|
|
}
|
|
}
|
|
|
|
// MARK: - Swipe To Reply
|
|
|
|
public override func findPanHandler(sender: UIPanGestureRecognizer,
|
|
componentDelegate: CVComponentDelegate,
|
|
componentView: CVComponentView,
|
|
renderItem: CVRenderItem,
|
|
swipeToReplyState: CVSwipeToReplyState) -> CVPanHandler? {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let componentView = componentView as? CVComponentViewMessage else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
return nil
|
|
}
|
|
|
|
if let audioAttachment = self.audioAttachment,
|
|
let subcomponentView = componentView.subcomponentView(key: .audioAttachment),
|
|
subcomponentView.rootView.containsGestureLocation(sender),
|
|
let panHandler = audioAttachment.findPanHandler(sender: sender,
|
|
componentDelegate: componentDelegate,
|
|
componentView: subcomponentView,
|
|
renderItem: renderItem,
|
|
swipeToReplyState: swipeToReplyState) {
|
|
return panHandler
|
|
}
|
|
|
|
let itemViewModel = CVItemViewModelImpl(renderItem: renderItem)
|
|
guard componentDelegate.cvc_shouldAllowReplyForItem(itemViewModel) else {
|
|
return nil
|
|
}
|
|
tryToUpdateSwipeToReplyReference(componentView: componentView,
|
|
renderItem: renderItem,
|
|
swipeToReplyState: swipeToReplyState)
|
|
guard swipeToReplyReference != nil else {
|
|
owsFailDebug("Missing reference[\(renderItem.interactionUniqueId)].")
|
|
return nil
|
|
}
|
|
|
|
return CVPanHandler(delegate: componentDelegate,
|
|
panType: .swipeToReply,
|
|
renderItem: renderItem)
|
|
}
|
|
|
|
public override func startPanGesture(sender: UIPanGestureRecognizer,
|
|
panHandler: CVPanHandler,
|
|
componentDelegate: CVComponentDelegate,
|
|
componentView: CVComponentView,
|
|
renderItem: CVRenderItem,
|
|
swipeToReplyState: CVSwipeToReplyState) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let componentView = componentView as? CVComponentViewMessage else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
return
|
|
}
|
|
owsAssertDebug(sender.state == .began)
|
|
|
|
switch panHandler.panType {
|
|
case .scrubAudio:
|
|
guard let audioAttachment = self.audioAttachment,
|
|
let subcomponentView = componentView.subcomponentView(key: .audioAttachment) else {
|
|
owsFailDebug("Missing audio attachment component.")
|
|
return
|
|
}
|
|
audioAttachment.startPanGesture(sender: sender,
|
|
panHandler: panHandler,
|
|
componentDelegate: componentDelegate,
|
|
componentView: subcomponentView,
|
|
renderItem: renderItem,
|
|
swipeToReplyState: swipeToReplyState)
|
|
case .swipeToReply:
|
|
tryToUpdateSwipeToReplyReference(componentView: componentView,
|
|
renderItem: renderItem,
|
|
swipeToReplyState: swipeToReplyState)
|
|
updateSwipeToReplyProgress(sender: sender,
|
|
panHandler: panHandler,
|
|
componentDelegate: componentDelegate,
|
|
renderItem: renderItem,
|
|
componentView: componentView,
|
|
swipeToReplyState: swipeToReplyState,
|
|
hasFinished: false)
|
|
tryToApplySwipeToReply(componentView: componentView, isAnimated: false)
|
|
}
|
|
}
|
|
|
|
public override func handlePanGesture(sender: UIPanGestureRecognizer,
|
|
panHandler: CVPanHandler,
|
|
componentDelegate: CVComponentDelegate,
|
|
componentView: CVComponentView,
|
|
renderItem: CVRenderItem,
|
|
swipeToReplyState: CVSwipeToReplyState) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let componentView = componentView as? CVComponentViewMessage else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
return
|
|
}
|
|
|
|
switch panHandler.panType {
|
|
case .scrubAudio:
|
|
guard let audioAttachment = self.audioAttachment,
|
|
let subcomponentView = componentView.subcomponentView(key: .audioAttachment) else {
|
|
owsFailDebug("Missing audio attachment component.")
|
|
return
|
|
}
|
|
audioAttachment.handlePanGesture(sender: sender,
|
|
panHandler: panHandler,
|
|
componentDelegate: componentDelegate,
|
|
componentView: subcomponentView,
|
|
renderItem: renderItem,
|
|
swipeToReplyState: swipeToReplyState)
|
|
case .swipeToReply:
|
|
let hasFinished: Bool
|
|
switch sender.state {
|
|
case .changed:
|
|
hasFinished = false
|
|
case .ended:
|
|
hasFinished = true
|
|
default:
|
|
clearSwipeToReply(componentView: componentView,
|
|
renderItem: renderItem,
|
|
swipeToReplyState: swipeToReplyState,
|
|
isAnimated: false)
|
|
return
|
|
}
|
|
updateSwipeToReplyProgress(sender: sender,
|
|
panHandler: panHandler,
|
|
componentDelegate: componentDelegate,
|
|
renderItem: renderItem,
|
|
componentView: componentView,
|
|
swipeToReplyState: swipeToReplyState,
|
|
hasFinished: hasFinished)
|
|
let hasFailed = sender.state == .failed || sender.state == .cancelled
|
|
let isAnimated = !hasFailed
|
|
tryToApplySwipeToReply(componentView: componentView, isAnimated: isAnimated)
|
|
if sender.state == .ended {
|
|
clearSwipeToReply(componentView: componentView,
|
|
renderItem: renderItem,
|
|
swipeToReplyState: swipeToReplyState,
|
|
isAnimated: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
public override func cellDidLayoutSubviews(componentView: CVComponentView,
|
|
renderItem: CVRenderItem,
|
|
swipeToReplyState: CVSwipeToReplyState) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let componentView = componentView as? CVComponentViewMessage else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
return
|
|
}
|
|
tryToUpdateSwipeToReplyReference(componentView: componentView,
|
|
renderItem: renderItem,
|
|
swipeToReplyState: swipeToReplyState)
|
|
tryToApplySwipeToReply(componentView: componentView, isAnimated: false)
|
|
}
|
|
|
|
public override func cellDidBecomeVisible(componentView: CVComponentView,
|
|
renderItem: CVRenderItem,
|
|
swipeToReplyState: CVSwipeToReplyState) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let componentView = componentView as? CVComponentViewMessage else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
return
|
|
}
|
|
tryToUpdateSwipeToReplyReference(componentView: componentView,
|
|
renderItem: renderItem,
|
|
swipeToReplyState: swipeToReplyState)
|
|
tryToApplySwipeToReply(componentView: componentView, isAnimated: false)
|
|
}
|
|
|
|
private func tryToUpdateSwipeToReplyReference(componentView: CVComponentViewMessage,
|
|
renderItem: CVRenderItem,
|
|
swipeToReplyState: CVSwipeToReplyState) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard swipeToReplyReference == nil else {
|
|
// Reference already set.
|
|
return
|
|
}
|
|
|
|
guard let contentView = componentView.swipeToReplyContentView else {
|
|
owsFailDebug("Missing outerContentView.")
|
|
return
|
|
}
|
|
let avatarView = componentView.avatarView
|
|
let iconView = componentView.swipeToReplyIconView
|
|
let cellHStack = componentView.cellHStack
|
|
|
|
let contentViewCenter = contentView.center
|
|
let avatarViewCenter = avatarView.center
|
|
let iconViewCenter = iconView.center
|
|
guard cellHStack.frame != .zero else {
|
|
// Cell has not been laid out yet.
|
|
return
|
|
}
|
|
var reactionsViewCenter: CGPoint?
|
|
if let reactionsView = componentView.reactionsView {
|
|
reactionsViewCenter = reactionsView.rootView.center
|
|
}
|
|
let reference = CVSwipeToReplyState.Reference(contentViewCenter: contentViewCenter,
|
|
reactionsViewCenter: reactionsViewCenter,
|
|
avatarViewCenter: avatarViewCenter,
|
|
iconViewCenter: iconViewCenter)
|
|
self.swipeToReplyReference = reference
|
|
}
|
|
|
|
private var swipeToReplyThreshold: CGFloat = 55
|
|
|
|
private func updateSwipeToReplyProgress(sender: UIPanGestureRecognizer,
|
|
panHandler: CVPanHandler,
|
|
componentDelegate: CVComponentDelegate,
|
|
renderItem: CVRenderItem,
|
|
componentView: CVComponentViewMessage,
|
|
swipeToReplyState: CVSwipeToReplyState,
|
|
hasFinished: Bool) {
|
|
AssertIsOnMainThread()
|
|
|
|
var xOffset = sender.translation(in: componentView.rootView).x
|
|
// Invert positions for RTL logic, since the user is swiping in the opposite direction.
|
|
if CurrentAppContext().isRTL {
|
|
xOffset = -xOffset
|
|
}
|
|
let hasFailed = sender.state == .failed || sender.state == .cancelled
|
|
let storedOffset = (hasFailed || hasFinished) ? 0 : xOffset
|
|
let progress = CVSwipeToReplyState.Progress(xOffset: storedOffset)
|
|
swipeToReplyState.setProgress(interactionId: renderItem.interactionUniqueId,
|
|
progress: progress)
|
|
self.swipeToReplyProgress = progress
|
|
|
|
let swipeToReplyIconView = componentView.swipeToReplyIconView
|
|
|
|
let isReplyActive = xOffset >= swipeToReplyThreshold
|
|
|
|
// If we're transitioning to the active state, play haptic feedback.
|
|
if isReplyActive, !panHandler.isReplyActive {
|
|
ImpactHapticFeedback.impactOccured(style: .light)
|
|
}
|
|
|
|
// Update the reply image styling to reflect active state
|
|
let isStarting = sender.state == .began
|
|
let didChange = isReplyActive != panHandler.isReplyActive
|
|
let shouldUpdateViews = isStarting || didChange
|
|
if shouldUpdateViews {
|
|
let shouldAnimate = didChange
|
|
let transform: CGAffineTransform
|
|
let tintColor: UIColor
|
|
if isReplyActive {
|
|
transform = CGAffineTransform(scaleX: 1.16, y: 1.16)
|
|
tintColor = isDarkThemeEnabled ? .ows_gray25 : .ows_gray75
|
|
} else {
|
|
transform = .identity
|
|
tintColor = .ows_gray45
|
|
}
|
|
swipeToReplyIconView.layer.removeAllAnimations()
|
|
swipeToReplyIconView.tintColor = tintColor
|
|
if shouldAnimate {
|
|
UIView.animate(withDuration: 0.2,
|
|
delay: 0,
|
|
usingSpringWithDamping: 0.06,
|
|
initialSpringVelocity: 0.8,
|
|
options: [.curveEaseInOut, .beginFromCurrentState],
|
|
animations: {
|
|
swipeToReplyIconView.transform = transform
|
|
},
|
|
completion: nil)
|
|
} else {
|
|
swipeToReplyIconView.transform = transform
|
|
}
|
|
}
|
|
|
|
panHandler.isReplyActive = isReplyActive
|
|
|
|
if panHandler.isReplyActive && hasFinished {
|
|
let itemViewModel = CVItemViewModelImpl(renderItem: renderItem)
|
|
componentDelegate.cvc_didTapReplyToItem(itemViewModel)
|
|
}
|
|
}
|
|
|
|
private func tryToApplySwipeToReply(componentView: CVComponentViewMessage,
|
|
isAnimated: Bool) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let contentView = componentView.swipeToReplyContentView else {
|
|
owsFailDebug("Missing outerContentView.")
|
|
return
|
|
}
|
|
guard let swipeToReplyReference = swipeToReplyReference,
|
|
let swipeToReplyProgress = swipeToReplyProgress else {
|
|
return
|
|
}
|
|
let swipeToReplyIconView = componentView.swipeToReplyIconView
|
|
let avatarView = componentView.avatarView
|
|
let iconView = componentView.swipeToReplyIconView
|
|
|
|
// Scale the translation above or below the desired range,
|
|
// to produce an elastic feeling when you overscroll.
|
|
var alpha = swipeToReplyProgress.xOffset
|
|
if alpha < 0 {
|
|
alpha = alpha / 4
|
|
} else if alpha > swipeToReplyThreshold {
|
|
let overflow = alpha - swipeToReplyThreshold
|
|
alpha = swipeToReplyThreshold + overflow / 4
|
|
}
|
|
let position = CurrentAppContext().isRTL ? -alpha : alpha
|
|
// The swipe content moves at 1/8th the speed of the message bubble,
|
|
// so that it reveals itself from underneath with an elastic feel.
|
|
let slowPosition = position / 8
|
|
|
|
var iconAlpha: CGFloat = 1
|
|
let useSwipeFadeTransition = isBorderless
|
|
if useSwipeFadeTransition {
|
|
iconAlpha = CGFloatInverseLerp(alpha, 0, swipeToReplyThreshold).clamp01()
|
|
}
|
|
|
|
let animations = {
|
|
swipeToReplyIconView.alpha = iconAlpha
|
|
contentView.center = swipeToReplyReference.contentViewCenter.plusX(position)
|
|
avatarView.center = swipeToReplyReference.avatarViewCenter.plusX(slowPosition)
|
|
iconView.center = swipeToReplyReference.iconViewCenter.plusX(slowPosition)
|
|
if let reactionsViewCenter = swipeToReplyReference.reactionsViewCenter,
|
|
let reactionsView = componentView.reactionsView {
|
|
reactionsView.rootView.center = reactionsViewCenter.plusX(position)
|
|
}
|
|
}
|
|
if isAnimated {
|
|
UIView.animate(withDuration: 0.1,
|
|
delay: 0,
|
|
options: [.beginFromCurrentState],
|
|
animations: animations,
|
|
completion: nil)
|
|
} else {
|
|
componentView.removeSwipeToReplyAnimations()
|
|
animations()
|
|
}
|
|
}
|
|
|
|
private func clearSwipeToReply(componentView: CVComponentViewMessage,
|
|
renderItem: CVRenderItem,
|
|
swipeToReplyState: CVSwipeToReplyState,
|
|
isAnimated: Bool) {
|
|
AssertIsOnMainThread()
|
|
|
|
swipeToReplyState.resetProgress(interactionId: renderItem.interactionUniqueId)
|
|
|
|
guard let contentView = componentView.swipeToReplyContentView else {
|
|
owsFailDebug("Missing outerContentView.")
|
|
return
|
|
}
|
|
let avatarView = componentView.avatarView
|
|
let iconView = componentView.swipeToReplyIconView
|
|
guard let swipeToReplyReference = swipeToReplyReference else {
|
|
return
|
|
}
|
|
|
|
let animations = {
|
|
contentView.center = swipeToReplyReference.contentViewCenter
|
|
avatarView.center = swipeToReplyReference.avatarViewCenter
|
|
iconView.center = swipeToReplyReference.iconViewCenter
|
|
iconView.alpha = 0
|
|
|
|
if let reactionsViewCenter = swipeToReplyReference.reactionsViewCenter,
|
|
let reactionsView = componentView.reactionsView {
|
|
reactionsView.rootView.center = reactionsViewCenter
|
|
}
|
|
}
|
|
|
|
if isAnimated {
|
|
UIView.animate(withDuration: 0.2, animations: animations)
|
|
} else {
|
|
componentView.removeSwipeToReplyAnimations()
|
|
animations()
|
|
}
|
|
|
|
self.swipeToReplyProgress = nil
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
fileprivate extension CVComponentMessage {
|
|
|
|
func configureSubcomponentView(messageView: CVComponentViewMessage,
|
|
subcomponent: CVComponent,
|
|
cellMeasurement: CVCellMeasurement,
|
|
componentDelegate: CVComponentDelegate,
|
|
key: CVComponentKey) -> CVComponentView {
|
|
if let subcomponentView = messageView.subcomponentView(key: key) {
|
|
subcomponent.configureForRendering(componentView: subcomponentView,
|
|
cellMeasurement: cellMeasurement,
|
|
componentDelegate: componentDelegate)
|
|
// TODO: Pin to measured height?
|
|
return subcomponentView
|
|
} else {
|
|
let subcomponentView = subcomponent.buildComponentView(componentDelegate: componentDelegate)
|
|
messageView.setSubcomponentView(key: key, subcomponentView: subcomponentView)
|
|
subcomponent.configureForRendering(componentView: subcomponentView,
|
|
cellMeasurement: cellMeasurement,
|
|
componentDelegate: componentDelegate)
|
|
// TODO: Pin to measured height?
|
|
return subcomponentView
|
|
}
|
|
}
|
|
|
|
func configureSubcomponent(messageView: CVComponentViewMessage,
|
|
cellMeasurement: CVCellMeasurement,
|
|
componentDelegate: CVComponentDelegate,
|
|
key: CVComponentKey) -> CVComponentAndView? {
|
|
guard let subcomponent = self.subcomponent(forKey: key) else {
|
|
return nil
|
|
}
|
|
let subcomponentView = configureSubcomponentView(messageView: messageView,
|
|
subcomponent: subcomponent,
|
|
cellMeasurement: cellMeasurement,
|
|
componentDelegate: componentDelegate,
|
|
key: key)
|
|
return CVComponentAndView(key: key, component: subcomponent, componentView: subcomponentView)
|
|
}
|
|
|
|
func buildNestedStackViewConfig(hasNeighborsAbove: Bool,
|
|
hasNeighborsBelow: Bool) -> CVStackViewConfig {
|
|
var layoutMargins = conversationStyle.textInsets
|
|
if hasNeighborsAbove {
|
|
layoutMargins.top = Self.textViewVSpacing
|
|
}
|
|
if hasNeighborsBelow {
|
|
layoutMargins.bottom = Self.textViewVSpacing
|
|
}
|
|
return CVStackViewConfig(axis: .vertical,
|
|
alignment: .fill,
|
|
spacing: Self.textViewVSpacing,
|
|
layoutMargins: layoutMargins)
|
|
}
|
|
|
|
func buildBorderlessStackViewConfig() -> CVStackViewConfig {
|
|
buildNoMarginsStackViewConfig()
|
|
}
|
|
|
|
func buildFullWidthStackViewConfig(includeTopMargin: Bool) -> CVStackViewConfig {
|
|
var layoutMargins = UIEdgeInsets.zero
|
|
if includeTopMargin {
|
|
layoutMargins.top = conversationStyle.textInsets.top
|
|
}
|
|
return CVStackViewConfig(axis: .vertical,
|
|
alignment: .fill,
|
|
spacing: Self.textViewVSpacing,
|
|
layoutMargins: layoutMargins)
|
|
}
|
|
|
|
func buildNoMarginsStackViewConfig() -> CVStackViewConfig {
|
|
CVStackViewConfig(axis: .vertical,
|
|
alignment: .fill,
|
|
spacing: Self.textViewVSpacing,
|
|
layoutMargins: .zero)
|
|
}
|
|
|
|
func configureSubcomponentStack(messageView: CVComponentViewMessage,
|
|
stackView: OWSStackView,
|
|
config: CVStackViewConfig,
|
|
cellMeasurement: CVCellMeasurement,
|
|
componentDelegate: CVComponentDelegate,
|
|
keys: [CVComponentKey]) {
|
|
|
|
stackView.apply(config: config)
|
|
|
|
for key in keys {
|
|
// TODO: configureSubcomponent should probably just return the componentView.
|
|
if let componentAndView = configureSubcomponentForStack(messageView: messageView,
|
|
axis: config.axis,
|
|
cellMeasurement: cellMeasurement,
|
|
componentDelegate: componentDelegate,
|
|
key: key) {
|
|
let subview = componentAndView.componentView.rootView
|
|
stackView.addArrangedSubview(subview)
|
|
}
|
|
}
|
|
}
|
|
|
|
func configureSubcomponentForStack(messageView: CVComponentViewMessage,
|
|
axis: NSLayoutConstraint.Axis,
|
|
cellMeasurement: CVCellMeasurement,
|
|
componentDelegate: CVComponentDelegate,
|
|
key: CVComponentKey) -> CVComponentAndView? {
|
|
|
|
guard let componentAndView = configureSubcomponent(messageView: messageView,
|
|
cellMeasurement: cellMeasurement,
|
|
componentDelegate: componentDelegate,
|
|
key: key) else {
|
|
return nil
|
|
}
|
|
let subview = componentAndView.componentView.rootView
|
|
|
|
// We pin the subcomponent view to its measured size
|
|
// during configuration for rendering.
|
|
if let subcomponentSize = cellMeasurement.size(key: key.asKey) {
|
|
switch axis {
|
|
case .horizontal:
|
|
owsAssertDebug(subcomponentSize.width > 0)
|
|
messageView.layoutConstraints.append(subview.autoSetDimension(.width, toSize: subcomponentSize.width))
|
|
case .vertical:
|
|
owsAssertDebug(subcomponentSize.height > 0)
|
|
messageView.layoutConstraints.append(subview.autoSetDimension(.height, toSize: subcomponentSize.height))
|
|
@unknown default:
|
|
owsFailDebug("Invalid axis.")
|
|
}
|
|
} else {
|
|
owsFailDebug("Missing size for key: \(key)")
|
|
}
|
|
|
|
return componentAndView
|
|
}
|
|
|
|
func subcomponents(forKeys keys: [CVComponentKey]) -> [CVComponent] {
|
|
keys.compactMap { key in
|
|
guard let subcomponent = self.subcomponent(forKey: key) else {
|
|
// Not all subcomponents may be present.
|
|
return nil
|
|
}
|
|
return subcomponent
|
|
}
|
|
}
|
|
|
|
func buildSubcomponentMap(keys: [CVComponentKey]) -> [CVComponentKey: CVComponent] {
|
|
var result = [CVComponentKey: CVComponent]()
|
|
for key in keys {
|
|
guard let subcomponent = self.subcomponent(forKey: key) else {
|
|
// Not all subcomponents may be present.
|
|
continue
|
|
}
|
|
result[key] = subcomponent
|
|
}
|
|
return result
|
|
}
|
|
}
|