// // 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 { 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 } }