// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // public import Foundation public import SignalServiceKit public import SignalUI import UIKit public class CVComponentMessage: CVComponentBase, CVRootComponent { public var componentKey: CVComponentKey { .messageRoot } public static let selectionAnimationDuration: TimeInterval = 0.2 public var cellReuseIdentifier: CVCellReuseIdentifier { .`default` } public var isDedicatedCell: Bool { 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 && conversationStyle.type != .messageDetails } 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 giftBadge: CVComponent? private var reactions: CVComponent? private var audioAttachment: CVComponent? private var genericAttachment: CVComponent? private var paymentAttachment: CVComponent? private var archivedPaymentAttachment: CVComponent? private var undownloadableAttachment: CVComponent? private var contactShare: CVComponent? private var bottomButtons: CVComponent? private var poll: CVComponent? private var bottomLabel: CVComponent? private var swipeActionProgress: CVMessageSwipeActionState.Progress? private var hasSendFailureBadge = false override init(itemModel: CVItemModel) { super.init(itemModel: itemModel) buildComponentStates() } var sharpCorners: OWSDirectionalRectCorner { var result: OWSDirectionalRectCorner = [] if !itemViewState.isFirstInCluster { result.insert(isIncoming ? .topLeading : .topTrailing) } if !itemViewState.isLastInCluster { result.insert(isIncoming ? .bottomLeading : .bottomTrailing) } return result } private var sharpCornersForQuotedMessage: OWSDirectionalRectCorner { var sharpCorners = sharpCorners if itemViewState.senderNameState != nil || componentState.quotedReply?.quotedReplyModel.storyReactionEmoji != nil { sharpCorners.insert(.topLeading) sharpCorners.insert(.topTrailing) } if componentState.bodyText != nil || !itemViewState.shouldHideFooter { sharpCorners.insert(.bottomLeading) sharpCorners.insert(.bottomTrailing) } return sharpCorners } 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 .paymentAttachment: return self.paymentAttachment case .archivedPaymentAttachment: return self.archivedPaymentAttachment case .undownloadableAttachment: return self.undownloadableAttachment case .quotedReply: return self.quotedReply case .linkPreview: return self.linkPreview case .giftBadge: return self.giftBadge case .reactions: return self.reactions case .contactShare: return self.contactShare case .bottomButtons: return self.bottomButtons case .poll: return self.poll case .bottomLabel: return self.bottomLabel // We don't render sender avatars with a subcomponent. case .senderAvatar: return nil case .systemMessage, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .skippedDownloads, .sendFailureBadge, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet, .messageRoot: return nil } } private var hasBodyMedia: 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 hasSecondaryContentForSelection: Bool { componentState.hasPrimaryAndSecondaryContentForSelection } private var isBubbleTransparent: Bool { if wasRemotelyDeleted { return false } if componentState.shouldRenderAsSticker { return true } return isBorderless } private var tapForMoreState: CVComponentFooter.TapForMoreState { standaloneFooter?.tapForMoreState ?? .none } private func footerOverlayIfItShouldShow() -> CVComponentFooter? { let footerShouldOverlay = (bodyText == nil && !itemViewState.shouldHideFooter && !tapForMoreState.shouldShowFooter) guard footerShouldOverlay else { return nil } if let footerState = itemViewState.footerState { return CVComponentFooter( itemModel: itemModel, footerState: footerState, isOverlayingMedia: false, isOutsideBubble: false, ) } else { owsFailDebug("Missing footerState.") } return nil } private func buildComponentStates() { hasSendFailureBadge = componentState.sendFailureBadge != nil var footerOverlay: CVComponentFooter? if let senderNameState = itemViewState.senderNameState { self.senderName = CVComponentSenderName(itemModel: itemModel, senderNameState: senderNameState) } if let senderAvatar = componentState.senderAvatar { self.senderAvatar = senderAvatar } if let undownloadableAttachment = componentState.undownloadableAttachment { footerOverlay = self.footerOverlayIfItShouldShow() self.undownloadableAttachment = CVComponentUndownloadableAttachment( itemModel: itemModel, attachmentType: undownloadableAttachment, footerOverlay: footerOverlay, ) } 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 genericAttachmentState = componentState.genericAttachment { self.genericAttachment = CVComponentGenericAttachment( itemModel: itemModel, genericAttachment: genericAttachmentState, ) } // Payments can have body text too; only render a vanilla body text if a payment // isn't present. if let bodyTextState = itemViewState.bodyTextState, componentState.paymentAttachment == nil { bodyText = CVComponentBodyText(itemModel: itemModel, bodyTextState: bodyTextState) } if let contactShareState = componentState.contactShare { contactShare = CVComponentContactShare( itemModel: itemModel, contactShareState: contactShareState, ) } if let pollState = componentState.poll { poll = CVComponentPoll(itemModel: itemModel, poll: pollState) } if let bottomButtonsState = componentState.bottomButtons { bottomButtons = CVComponentBottomButtons( itemModel: itemModel, bottomButtonsState: bottomButtonsState, ) } if let bottomLabelState = componentState.bottomLabel { bottomLabel = CVComponentBottomLabel(itemModel: itemModel, bottomLabelState: bottomLabelState) } if let paymentAttachment = componentState.paymentAttachment { let paymentAmount: UInt64? = { let receipt = paymentAttachment.notification.mcReceiptData guard let decryptedAmount = SUIEnvironment.shared.paymentsImplRef.unmaskReceiptAmount(data: receipt) else { // Valid path for sender return paymentAttachment.model?.paymentAmount?.picoMob } // Valid path for recipient return decryptedAmount.value }() let messageStatus: MessageReceiptStatus? = { guard let outgoingMessage = itemModel.interaction as? OWSOutgoingPaymentMessage, let model = paymentAttachment.model else { return nil } return MessageRecipientStatusUtils.recipientStatus( outgoingMessage: outgoingMessage, paymentModel: model, ) }() if let footerState = itemViewState.footerState { self.standaloneFooter = CVComponentFooter( itemModel: itemModel, footerState: footerState, isOverlayingMedia: false, isOutsideBubble: false, ) } self.paymentAttachment = CVComponentPaymentAttachment( itemModel: itemModel, paymentAttachment: paymentAttachment, paymentModel: paymentAttachment.model, contactName: paymentAttachment.otherUserShortName, paymentAmount: paymentAmount, messageStatus: messageStatus, ) } if let archivedPaymentAttachment = componentState.archivedPaymentAttachment { let messageStatus: MessageReceiptStatus? = { guard let outgoingMessage = self.itemModel.interaction as? TSOutgoingMessage else { return nil } return MessageRecipientStatusUtils.recipientStatus( outgoingMessage: outgoingMessage, hasBodyAttachments: false, ) }() if let footerState = itemViewState.footerState { self.standaloneFooter = CVComponentFooter( itemModel: itemModel, footerState: footerState, isOverlayingMedia: false, isOutsideBubble: false, ) } self.archivedPaymentAttachment = CVComponentArchivedPayment( itemModel: itemModel, archivedPaymentAttachment: archivedPaymentAttachment, messageStatus: messageStatus, ) } if let audioAttachmentState = componentState.audioAttachment { footerOverlay = self.footerOverlayIfItShouldShow() self.audioAttachment = CVComponentAudioAttachment( itemModel: itemModel, audioAttachment: audioAttachmentState, nextAudioAttachment: itemViewState.nextAudioAttachment, footerOverlay: footerOverlay, ) } if let bodyMediaState = componentState.bodyMedia { let shouldFooterOverlayMedia = (bodyText == nil && !isBorderless && !itemViewState.shouldHideFooter && !tapForMoreState.shouldShowFooter) if shouldFooterOverlayMedia { owsAssertDebug(footerOverlay == nil) 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, linkPreview: linkPreviewState.state, ) } if let giftBadge = componentState.giftBadge, let viewState = itemViewState.giftBadgeState { self.giftBadge = CVComponentGiftBadge(itemModel: itemModel, giftBadge: giftBadge, viewState: viewState) } if let reactionsState = componentState.reactions { self.reactions = CVComponentReactions(itemModel: itemModel, reactions: reactionsState) } } public func configureCellRootComponent( cellView: UIView, cellMeasurement: CVCellMeasurement, componentDelegate: CVComponentDelegate, messageSwipeActionState: CVMessageSwipeActionState, componentView: CVComponentView, ) { Self.configureCellRootComponent( rootComponent: self, cellView: cellView, cellMeasurement: cellMeasurement, componentDelegate: componentDelegate, componentView: componentView, ) self.swipeActionProgress = messageSwipeActionState.getProgress(interactionId: interaction.uniqueId) } public func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView { CVComponentViewMessage() } override public func wallpaperBlurView(componentView: CVComponentView) -> CVWallpaperBlurView? { guard let componentView = componentView as? CVComponentViewMessage else { owsFailDebug("Unexpected componentView.") return nil } return componentView.wallpaperBlurView } override public func updateScrollingContent(componentView: CVComponentView) { super.updateScrollingContent(componentView: componentView) guard let componentView = componentView as? CVComponentViewMessage else { owsFailDebug("Unexpected componentView.") return } componentView.chatColorView.updateAppearance() // We propagate this event to all subcomponents that use the CVColorOrGradientView. let keys: [CVComponentKey] = [.senderName, .footer] for key in keys { if let subcomponentAndView = findActiveComponentAndView( key: key, messageView: componentView, ignoreMissing: true, ) { let subcomponent = subcomponentAndView.component let subcomponentView = subcomponentAndView.componentView subcomponent.updateScrollingContent(componentView: subcomponentView) } } } public static let textViewVSpacing: CGFloat = 3 private var sendFailureBadgeSize: CGFloat { conversationStyle.hasWallpaper ? 40 : 24 } public static let bubbleSharpCornerRadius: CGFloat = 4 public static let bubbleWideCornerRadius: CGFloat = 18 public func configureForRendering( componentView: CVComponentView, cellMeasurement: CVCellMeasurement, componentDelegate: CVComponentDelegate, ) { guard let componentView = componentView as? CVComponentViewMessage else { owsFailDebug("Unexpected componentView.") return } let outerContentView = configureContentStack( componentView: componentView, cellMeasurement: cellMeasurement, componentDelegate: componentDelegate, ) // No bubbles for stickers. var outerBubbleView: (CVDimmableView & OWSBubbleViewHost)? if nil == subcomponent(forKey: .sticker) { let bubbleConfiguration = BubbleConfiguration( corners: .segmented( sharpCorners: sharpCorners, sharpCornerRadius: Self.bubbleSharpCornerRadius, wideCornerRadius: Self.bubbleWideCornerRadius, ), stroke: bubbleStroke, ) if case .blur = bubbleChatColor { let wallpaperBlurView = componentView.ensureWallpaperBlurView() configureWallpaperBlurView( wallpaperBlurView: wallpaperBlurView, componentDelegate: componentDelegate, bubbleConfig: bubbleConfiguration, ) outerBubbleView = wallpaperBlurView } else { let chatColorView = componentView.chatColorView chatColorView.configure( value: bubbleChatColor, referenceView: componentDelegate.view, bubbleConfig: bubbleConfiguration, ) outerBubbleView = chatColorView } } // hInnerStack let hInnerStack = componentView.hInnerStack hInnerStack.reset() var hInnerStackSubviews = [UIView]() if hasSenderAvatarLayout, let senderAvatar = self.senderAvatar { if hasSenderAvatar { componentView.avatarView.updateWithSneakyTransactionIfNecessary { config in config.dataSource = senderAvatar.avatarDataSource } } // Add the view wrapper, not the view. hInnerStackSubviews.append(componentView.avatarViewSwipeToReplyWrapper) } let contentViewSwipeToReplyWrapper = componentView.contentViewSwipeToReplyWrapper if let bubbleView = outerBubbleView { bubbleView.addSubviewToFillSuperviewEdges(outerContentView) if let (giftWrapView, bubbleViewPartner) = self.configureGiftWrapIfNeeded(messageView: componentView) { let wrapper = ManualLayoutView(name: "containerForOverlay") wrapper.addSubviewToFillSuperviewEdges(bubbleView) wrapper.addSubviewToFillSuperviewEdges(giftWrapView) contentViewSwipeToReplyWrapper.subview = wrapper bubbleViewPartner.setBubbleViewHost(bubbleView) } else { contentViewSwipeToReplyWrapper.subview = bubbleView } if let componentAndView = findActiveComponentAndView( key: .bodyMedia, messageView: componentView, ) { if let bodyMediaComponent = componentAndView.component as? CVComponentBodyMedia { if let bubbleViewPartner = bodyMediaComponent.bubbleViewPartner(componentView: componentAndView.componentView) { bubbleViewPartner.setBubbleViewHost(bubbleView) contentViewSwipeToReplyWrapper.addLayoutBlock { _ in // The "bubble view partner" must update it's layers // to reflect the bubble view state. bubbleViewPartner.updateLayers() } hInnerStack.addLayoutBlock { _ in // The "bubble view partner" must update it's layers // to reflect the bubble view state. bubbleViewPartner.updateLayers() } outerBubbleView?.dimsContent = true } } else { owsFailDebug("Invalid component.") } } } else { contentViewSwipeToReplyWrapper.subview = outerContentView } // Use the view wrapper, not the view. let contentRootView = contentViewSwipeToReplyWrapper hInnerStackSubviews.append(contentRootView) hInnerStack.configure( config: hInnerStackConfig, cellMeasurement: cellMeasurement, measurementKey: Self.measurementKey_hInnerStack, subviews: hInnerStackSubviews, ) // hOuterStack var hOuterStackSubviews = [UIView]() if isShowingSelectionUI || wasShowingSelectionUI { let primarySelectionView = componentView.primarySelectionView primarySelectionView.isSelected = componentDelegate.selectionState.isSelected( interaction.uniqueId, selectionType: .primaryContent, ) primarySelectionView.updateStyle(conversationStyle: conversationStyle) let selectionWrapper = componentView.selectionWrapper if hasSecondaryContentForSelection, let bodyTextRootView = CVComponentBodyText.findBodyTextRootView(outerContentView) { struct SelectionLayoutHelper { let outerContentView: UIView let bodyTextRootView: UIView func applyLayout(bottomSelectionView: UIView, topSelectionView: UIView?) { let size = MessageSelectionView.preferredSize guard let superview = bottomSelectionView.superview else { owsFailDebug("Missing superview.") return } // Determine the frame of the body text view in the local // coordinate system. let bodyTextFrame = superview.convert(bodyTextRootView.bounds, from: bodyTextRootView) let outerContentFrame = superview.convert(outerContentView.bounds, from: outerContentView) if let topSelectionView { // "Top" should center-align with the area above the body text. let topY = bodyTextFrame.y * 0.5 - size.height * 0.5 topSelectionView.frame = CGRect(origin: CGPoint(x: 0, y: topY), size: size) } // "Bottom" should center-align with the body text and the content below it. // // This bakes in the assumption that the group sender avatar will // be bottom-aligned with the bottom of the message bubble. let bottomMidY = bodyTextFrame.minY.average(outerContentFrame.maxY) let bottomY = bottomMidY - size.height * 0.5 bottomSelectionView.frame = CGRect(origin: CGPoint(x: 0, y: bottomY), size: size) } } let selectionLayoutHelper = SelectionLayoutHelper( outerContentView: outerContentView, bodyTextRootView: bodyTextRootView, ) let secondarySelectionView = componentView.secondarySelectionView secondarySelectionView.isSelected = componentDelegate.selectionState.isSelected( interaction.uniqueId, selectionType: .secondaryContent, ) secondarySelectionView.updateStyle(conversationStyle: conversationStyle) let selectionLayoutBlock = { (_: UIView) -> Void in selectionLayoutHelper.applyLayout( bottomSelectionView: secondarySelectionView, topSelectionView: primarySelectionView, ) } // When doing "partial" selection, the selection UI needs to // align with the corresponding content views. // // Coordinating layout of "distant cousin" views in a view hierarchy // is trivial with iOS Auto Layout, but hard with manual layout, since // changes to any "intermediary" relative can affect the coordination, // and for a rich view hierarchy it's not practical to observe all of // the "intermediaries". It's also impractical to calculate their // relative positions using the "measurement/layout" state. // // Therefore, we coordinate their layouts by: // // * Using layout blocks that use the actual final layouts. // * Adding the layout block (selectionLayoutBlock) to both // the immediate parent (as usual) and to the "oldest common // ancestor" (unusual). The latter ensures that we re-layout // whenever the cell changes size, for example. selectionWrapper.addSubview(primarySelectionView, withLayoutBlock: { _ in }) selectionWrapper.addSubview(secondarySelectionView, withLayoutBlock: { _ in }) selectionWrapper.addLayoutBlock(selectionLayoutBlock) outerContentView.addLayoutBlock(selectionLayoutBlock) outerContentView.setNeedsLayout() } else { selectionWrapper.addSubviewToCenterOnSuperview( primarySelectionView, size: MessageSelectionView.preferredSize, ) } hOuterStackSubviews.append(selectionWrapper) } if isOutgoing { hOuterStackSubviews.append(componentView.cellSpacer) } hOuterStackSubviews.append(hInnerStack) if isIncoming { hOuterStackSubviews.append(componentView.cellSpacer) } if let badgeConfig = componentState.sendFailureBadge { // Send failures are rare, so it's cheaper to only build these views when we need them. let badgeImageView = CVImageView() badgeImageView.contentMode = .center badgeImageView.setTemplateImageName("error-circle", tintColor: badgeConfig.color) let sendFailureBadge: UIView if let visualEffect = conversationStyle.bubbleBackgroundBlurEffect { let circleView = ManualLayoutView.circleView(name: "SendFailureBadge") circleView.layer.masksToBounds = true circleView.addSubviewToFillSuperviewEdges(UIVisualEffectView(effect: visualEffect)) circleView.addSubviewToFillSuperviewEdges(badgeImageView) sendFailureBadge = circleView } else { sendFailureBadge = badgeImageView } let sendFailureWrapper = ManualLayoutView(name: "sendFailureWrapper") hOuterStackSubviews.append(sendFailureWrapper) sendFailureWrapper.addSubview(sendFailureBadge) let sendFailureBadgeSize = self.sendFailureBadgeSize let conversationStyle = self.conversationStyle sendFailureWrapper.addLayoutBlock { view in var sendFailureFrame = CGRect( origin: .zero, size: CGSize(square: sendFailureBadgeSize), ) // Bottom align. sendFailureFrame.y = view.bounds.height - sendFailureFrame.height if !conversationStyle.hasWallpaper { let sendFailureBadgeBottomMargin = round(conversationStyle.lastTextLineAxis - sendFailureBadgeSize * 0.5) sendFailureFrame.y -= sendFailureBadgeBottomMargin } sendFailureBadge.frame = sendFailureFrame } } let hOuterStack = componentView.hOuterStack hOuterStack.reset() hOuterStack.configure( config: hOuterStackConfig, cellMeasurement: cellMeasurement, measurementKey: Self.measurementKey_hOuterStack, subviews: hOuterStackSubviews, ) let swipeToReplyIconView = componentView.swipeToReplyIconView swipeToReplyIconView.backgroundEffect = conversationStyle.bubbleBackgroundBlurEffect swipeToReplyIconView.alpha = 0 let swipeToReplyIconSwipeToReplyWrapper = componentView.swipeToReplyIconSwipeToReplyWrapper // Add the view wrapper, not the view. let swipeToReplyView = swipeToReplyIconSwipeToReplyWrapper hInnerStack.addSubview(swipeToReplyView) hInnerStack.sendSubviewToBack(swipeToReplyView) hInnerStack.addLayoutBlock { _ in guard let superview = swipeToReplyView.superview else { return } let contentFrame = superview.convert(contentRootView.bounds, from: contentRootView) let swipeToReplySize = swipeToReplyIconView.intrinsicContentSize var swipeToReplyFrame = CGRect(origin: .zero, size: swipeToReplySize) // swipeToReplyIconView.autoPinEdge(.leading, to: .leading, of: swipeActionContentView, withOffset: 8) if CurrentAppContext().isRTL { swipeToReplyFrame.x = contentFrame.maxX - (swipeToReplySize.width + 8) } else { swipeToReplyFrame.x = contentFrame.x + 8 } // swipeToReplyIconView.autoAlignAxis(.horizontal, toSameAxisOf: swipeActionContentView) swipeToReplyFrame.y = contentFrame.y + (contentFrame.height - swipeToReplyFrame.height) * 0.5 swipeToReplyView.frame = swipeToReplyFrame } if let reactions = self.reactions, let reactionsSize = cellMeasurement.size(key: Self.measurementKey_reactions) { let reactionsView = configureSubcomponentView( messageView: componentView, subcomponent: reactions, cellMeasurement: cellMeasurement, componentDelegate: componentDelegate, key: .reactions, ) // Use the view wrapper, not the view. let reactionsSwipeToReplyWrapper = componentView.reactionsSwipeToReplyWrapper reactionsSwipeToReplyWrapper.subview = reactionsView.rootView let reactionsRootView = reactionsSwipeToReplyWrapper hInnerStack.addSubview(reactionsRootView) let reactionsVOverlap = self.reactionsVOverlap let reactionsHInset = self.reactionsHInset let isIncoming = self.isIncoming // 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. hInnerStack.addLayoutBlock { _ in guard let superview = reactionsRootView.superview else { return } let contentFrame = superview.convert(outerContentView.bounds, from: outerContentView) var reactionsFrame = CGRect(origin: .zero, size: reactionsSize) reactionsFrame.y = contentFrame.maxY - reactionsVOverlap let leftAlignX = contentFrame.minX + reactionsHInset let rightAlignX = contentFrame.maxX - (reactionsSize.width + reactionsHInset) if isIncoming != CurrentAppContext().isRTL { reactionsFrame.x = max(leftAlignX, rightAlignX) } else { reactionsFrame.x = min(leftAlignX, rightAlignX) } reactionsRootView.frame = reactionsFrame } } if poll == nil { // Polls manage accessibility manually. componentView.hInnerStack.accessibilityLabel = buildAccessibilityLabel(componentView: componentView) componentView.hInnerStack.isAccessibilityElement = true } var selectionViews: [ManualLayoutView] = [componentView.primarySelectionView] if hasSecondaryContentForSelection { selectionViews.append(componentView.secondarySelectionView) } // Configure hOuterStack/hInnerStack animations if isShowingSelectionUI || wasShowingSelectionUI { // Configure selection animations let selectionViewWidth = ConversationStyle.selectionViewWidth let layoutMargins = CurrentAppContext().isRTL ? hOuterStackConfig.layoutMargins.right : hOuterStackConfig.layoutMargins.left let selectionOffset = -(layoutMargins + selectionViewWidth) let hInnerStackOffset = -(hOuterStackConfig.spacing + selectionViewWidth) if isShowingSelectionUI, !wasShowingSelectionUI { // Animate in for selectionView in selectionViews { selectionView.addTransformBlock { view in let animation = CABasicAnimation(keyPath: "transform.translation.x") animation.fromValue = selectionOffset animation.toValue = 0 animation.duration = CVComponentMessage.selectionAnimationDuration animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) view.layer.add(animation, forKey: "insert") } } if isIncoming { hInnerStack.addTransformBlock { view in let animation = CABasicAnimation(keyPath: "transform.translation.x") animation.fromValue = hInnerStackOffset animation.toValue = 0 animation.duration = CVComponentMessage.selectionAnimationDuration animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) view.layer.add(animation, forKey: "insert") } } } else if !isShowingSelectionUI, wasShowingSelectionUI { // Animate out for selectionView in selectionViews { selectionView.addTransformBlock { view in let animation = CABasicAnimation(keyPath: "transform.translation.x") animation.fromValue = 0 animation.toValue = selectionOffset animation.duration = CVComponentMessage.selectionAnimationDuration animation.isRemovedOnCompletion = false animation.repeatCount = 0 animation.fillMode = CAMediaTimingFillMode.forwards animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) view.layer.add(animation, forKey: "remove") } } if isIncoming { hInnerStack.addTransformBlock { view in let animation = CABasicAnimation(keyPath: "transform.translation.x") animation.fromValue = 0 animation.toValue = hInnerStackOffset animation.duration = CVComponentMessage.selectionAnimationDuration animation.isRemovedOnCompletion = false animation.repeatCount = 0 animation.fillMode = CAMediaTimingFillMode.forwards animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) view.layer.add(animation, forKey: "remove") } } } } else { // Remove outstanding animations if needed for selectionView in selectionViews { selectionView.invalidateTransformBlocks() } hInnerStack.invalidateTransformBlocks() } hOuterStack.applyTransformBlocks() } // The behavior of this method has to align exactly with that of measureContentStack(). private func configureContentStack( componentView: CVComponentViewMessage, cellMeasurement: CVCellMeasurement, componentDelegate: CVComponentDelegate, ) -> ManualLayoutView { let stickerOverlaySubcomponent = subcomponent(forKey: .sticker) func configureStackView( _ stackView: ManualStackView, stackConfig: CVStackViewConfig, measurementKey: String, componentKeys keys: [CVComponentKey], ) -> ManualStackView { self.configureSubcomponentStack( messageView: componentView, stackView: stackView, stackConfig: stackConfig, cellMeasurement: cellMeasurement, measurementKey: measurementKey, componentDelegate: componentDelegate, keys: keys, ) return stackView } if nil != stickerOverlaySubcomponent { // Sticker message. // // Stack is borderless. // // Optional senderName and footer. return configureStackView( componentView.contentStack, stackConfig: buildBorderlessStackConfig(), measurementKey: Self.measurementKey_contentStack, componentKeys: [.senderName, .sticker, .footer], ) } else { // The non-sticker case. // Use multiple stacks. let contentSections = buildContentSections() var contentSubviews = [UIView]() enumerate(contentSections: contentSections) { (contentSection: ContentSection, stackConfig: CVStackViewConfig) in guard let stackView = contentSection.stackView(componentView: componentView), let stackMeasurementKey = contentSection.stackMeasurementKey else { owsFailDebug("Missing stackView or stackMeasurementKey.") return } let componentKeys = contentSection.components.map { $0.componentKey } var stackConfig = stackConfig if contentSection.sectionType == .bottomNestedText, let bottomNestedTextSpacing = cellMeasurement.value(key: Self.measurementKey_bottomNestedTextSpacing) { stackConfig = stackConfig.withSpacing(bottomNestedTextSpacing) } _ = configureStackView( stackView, stackConfig: stackConfig, measurementKey: stackMeasurementKey, componentKeys: componentKeys, ) contentSubviews.append(stackView) } // Append the bottom buttons if necessary. if nil != bottomButtons { if let componentAndView = configureSubcomponent( messageView: componentView, cellMeasurement: cellMeasurement, componentDelegate: componentDelegate, key: .bottomButtons, ) { let subview = componentAndView.componentView.rootView contentSubviews.append(subview) } else { owsFailDebug("Couldn't configure bottomButtons.") } } if nil != bottomLabel { if let componentAndView = configureSubcomponent( messageView: componentView, cellMeasurement: cellMeasurement, componentDelegate: componentDelegate, key: .bottomLabel, ) { let subview = componentAndView.componentView.rootView contentSubviews.append(subview) } else { owsFailDebug("Couldn't configure bottomLabel.") } } let contentStack = componentView.contentStack contentStack.reset() contentStack.configure( config: buildContentStackConfig(), cellMeasurement: cellMeasurement, measurementKey: Self.measurementKey_contentStack, subviews: contentSubviews, ) return contentStack } } private func configureGiftWrapIfNeeded( messageView componentView: CVComponentViewMessage, ) -> (ManualLayoutView, OWSBubbleViewPartner)? { guard let componentAndView = self.findActiveComponentAndView(key: .giftBadge, messageView: componentView), let giftBadgeComponent = componentAndView.component as? CVComponentGiftBadge else { return nil } return giftBadgeComponent.configureGiftWrapIfNeeded(componentView: componentAndView.componentView) } // 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 static var topFullWidthCVComponentKeys: [CVComponentKey] { [] } private static var topNestedCVComponentKeys: [CVComponentKey] { [.senderName] } private static var bottomFullWidthCVComponentKeys: [CVComponentKey] { [ .quotedReply, .linkPreview, .bodyMedia, ] } private static var bottomNestedShareCVComponentKeys: [CVComponentKey] { [ .viewOnce, .audioAttachment, .genericAttachment, .paymentAttachment, .archivedPaymentAttachment, .contactShare, .giftBadge, .poll, ] } private static var bottomNestedTextCVComponentKeys: [CVComponentKey] { [.bodyText, .footer, .undownloadableAttachment] } // The "message" contents of this component for most messages are vertically // stacked in four sections. fileprivate enum SectionType: CaseIterable { case topFullWidth case topNested case bottomFullWidth case bottomNestedShare case bottomNestedText case bottomButtons case bottomLabel // Ordering of the keys in each section determines the ordering of the subcomponents. var hasStack: Bool { switch self { case .topFullWidth, .topNested, .bottomFullWidth, .bottomNestedShare, .bottomNestedText: return true case .bottomButtons, .bottomLabel: return false } } var isNestedSection: Bool { switch self { case .topNested, .bottomNestedShare, .bottomNestedText: return true case .topFullWidth, .bottomFullWidth, .bottomButtons, .bottomLabel: return false } } // Ordering of the keys in each section determines the ordering of the subcomponents. var possibleComponentKeys: [CVComponentKey] { switch self { case .topFullWidth: return CVComponentMessage.topFullWidthCVComponentKeys case .topNested: return CVComponentMessage.topNestedCVComponentKeys case .bottomFullWidth: return CVComponentMessage.bottomFullWidthCVComponentKeys case .bottomNestedShare: return CVComponentMessage.bottomNestedShareCVComponentKeys case .bottomNestedText: return CVComponentMessage.bottomNestedTextCVComponentKeys case .bottomButtons: return [.bottomButtons] case .bottomLabel: return [.bottomLabel] } } var stackMeasurementKey: String? { switch self { case .topFullWidth: return CVComponentMessage.measurementKey_topFullWidthStackView case .topNested: return CVComponentMessage.measurementKey_topNestedStackView case .bottomFullWidth: return CVComponentMessage.measurementKey_bottomFullWidthStackView case .bottomNestedShare: return CVComponentMessage.measurementKey_bottomNestedShareStackView case .bottomNestedText: return CVComponentMessage.measurementKey_bottomNestedTextStackView case .bottomButtons, .bottomLabel: owsFailDebug("Invalid section") return nil } } func stackView(componentView: CVComponentViewMessage) -> ManualStackView? { switch self { case .topFullWidth: return componentView.topFullWidthStackView case .topNested: return componentView.topNestedStackView case .bottomFullWidth: return componentView.bottomFullWidthStackView case .bottomNestedShare: return componentView.bottomNestedShareStackView case .bottomNestedText: return componentView.bottomNestedTextStackView case .bottomButtons, .bottomLabel: return nil } } } fileprivate struct ContentSection { let sectionType: SectionType let components: [CVComponent] var stackMeasurementKey: String? { sectionType.stackMeasurementKey } func stackView(componentView: CVComponentViewMessage) -> ManualStackView? { sectionType.stackView(componentView: componentView) } } fileprivate func buildContentSections() -> [ContentSection] { var contentSections = [ContentSection]() for sectionType in SectionType.allCases { let subcomponents = subcomponents(forKeys: sectionType.possibleComponentKeys) if !subcomponents.isEmpty { contentSections.append(ContentSection( sectionType: sectionType, components: subcomponents, )) } } return contentSections } fileprivate func enumerate( contentSections: [ContentSection], block: (ContentSection, CVStackViewConfig) -> Void, ) { for (currentSectionIndex, contentSection) in contentSections.enumerated() { guard !contentSection.components.isEmpty, let firstComponent = contentSection.components.first, let lastComponent = contentSection.components.last else { owsFailDebug("Empty content section.") continue } let sectionType = contentSection.sectionType // Only enumate the sections that use stacks. guard sectionType.hasStack else { continue } let previousSections = contentSections.enumerated().compactMap { sectionIndex, section in sectionIndex < currentSectionIndex ? section : nil } let previousSectionItems = Array(previousSections.map { section in section.components.map { component in SectionItem(sectionType: section.sectionType, component: component) } }.joined()) let nextSections = contentSections.enumerated().compactMap { sectionIndex, section in sectionIndex > currentSectionIndex ? section : nil } let nextSectionItems = Array(nextSections.map { section in section.components.map { component in SectionItem(sectionType: section.sectionType, component: component) } }.joined()) let firstSectionItem = SectionItem(sectionType: sectionType, component: firstComponent) let lastSectionItem = SectionItem(sectionType: sectionType, component: lastComponent) let stackConfig = contentSectionStackConfig( sectionType: sectionType, firstSectionItem: firstSectionItem, lastSectionItem: lastSectionItem, previousSectionItems: previousSectionItems, nextSectionItems: nextSectionItems, ) block(contentSection, stackConfig) } } fileprivate func contentSectionStackConfig( sectionType: SectionType, firstSectionItem: SectionItem, lastSectionItem: SectionItem, previousSectionItems: [SectionItem], nextSectionItems: [SectionItem], ) -> CVStackViewConfig { switch sectionType { case .topFullWidth: return buildFullWidthStackConfig(includeTopMargin: false, includeBottomMargin: false) case .bottomFullWidth: var applyTopMargin = false var applyBottomMargin = false if previousSectionItems.isEmpty, quotedReply != nil || linkPreview != nil { applyTopMargin = true applyBottomMargin = bodyText == nil && standaloneFooter == nil && bodyMedia == nil } else if let previousSectionItem = previousSectionItems.last, previousSectionItem.componentKey == .linkPreview, quotedReply != nil { applyTopMargin = true } return buildFullWidthStackConfig(includeTopMargin: applyTopMargin, includeBottomMargin: applyBottomMargin) case .topNested, .bottomNestedShare, .bottomNestedText: let topMargin: ContentStackMargin if let previousSectionItem = previousSectionItems.last { // The top margin of a section's stack reflects the first item // in the section and the previous item (if any) before the stack. topMargin = contentStackMarginBetweenComponents( marginType: .top, topSectionItem: previousSectionItem, bottomSectionItem: firstSectionItem, ) } else { // If this is the first section stack, it should use the outer margin. topMargin = .topMargin } var bottomMargin: ContentStackMargin = .none if let nextSectionItem = nextSectionItems.first { // The bottom margin of a section's stack reflects the last item // in the section and the next item (if any) after the stack. bottomMargin = contentStackMarginBetweenComponents( marginType: .bottom, topSectionItem: lastSectionItem, bottomSectionItem: nextSectionItem, ) } else { // If this is the last section stack, it should use the outer margin. bottomMargin = .bottomMargin } return buildNestedStackConfig( topMargin: topMargin, bottomMargin: bottomMargin, ) case .bottomButtons, .bottomLabel: owsFailDebug("Section does not use a stack.") return CVStackViewConfig(axis: .vertical, alignment: .center, spacing: 0, layoutMargins: .zero) } } fileprivate enum ContentStackMarginType { case top case bottom } fileprivate struct SectionItem { let sectionType: SectionType let component: CVComponent var componentKey: CVComponentKey { component.componentKey } } private func contentStackMarginBetweenComponents( marginType: ContentStackMarginType, topSectionItem: SectionItem, bottomSectionItem: SectionItem, ) -> ContentStackMargin { let topComponentKey = topSectionItem.componentKey let bottomComponentKey = bottomSectionItem.componentKey // Special case: Bottom buttons and labels are not in a stack. if bottomComponentKey == .bottomButtons || bottomComponentKey == .bottomLabel { return .bottomMargin } // We use stack margins to create spacing between content sections. // // We only place the spacing on "nested" stacks. // So one of the two sections should be nested. owsAssertDebug( topSectionItem.sectionType.isNestedSection || bottomSectionItem.sectionType.isNestedSection, ) // If two "nested" sections are adjacent, we don't want to create the // margin on both stacks, that would double the spacing. if topSectionItem.sectionType.isNestedSection, bottomSectionItem.sectionType.isNestedSection { // If both sections are "nested", arbitrarily chose one. if marginType == .bottom { return .none } } func isLargeComponent(_ componentKey: CVComponentKey) -> Bool { switch componentKey { case .bodyText: return false case .bodyMedia, .sticker, .quotedReply, .linkPreview, .viewOnce, .audioAttachment, .genericAttachment, .paymentAttachment, .archivedPaymentAttachment, .contactShare: return true case .undownloadableAttachment: return false case .giftBadge: // TODO: (GB) Confirm that Gift Badges should use large component spacing. return true case .senderName: return false case .senderAvatar, .reactions, .systemMessage, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .unknownThreadWarning, .skippedDownloads, .sendFailureBadge, .defaultDisappearingMessageTimer, .collapseSet, .messageRoot: owsFailDebug("Unexpected component.") return false case .footer: return false case .bottomButtons, .bottomLabel: return true case .poll: return true } } // Special case: Sender name and body text. if topComponentKey == .senderName, bottomComponentKey == .bodyText { return .spacingCustom(spacing: 1) } // Special case: Sender name and quoted reply. if topComponentKey == .senderName, bottomComponentKey == .quotedReply { return .spacingCustom(spacing: 5) } // Special case: Quoted reply and "large" components. if topComponentKey == .quotedReply, isLargeComponent(bottomComponentKey) { return .spacingCustom(spacing: 8) } // Special case: Contact share and footer. if topComponentKey == .contactShare, bottomComponentKey == .footer { return .spacingCustom(spacing: 5) } // Use extra-large spacing after "large" components. if isLargeComponent(topComponentKey) { if bottomComponentKey == .footer { return .spacingCustom(spacing: 5) } else { return .spacingCustom(spacing: 7) } } // Use large spacing before "large" components. if isLargeComponent(bottomComponentKey) { return .spacingCustom(spacing: 5) } return .spacingDefault } // Builds an accessibility label for the entire message. // This label uses basic punctuation which might be used by // VoiceOver for pauses/timing. // // Example: Lilia sent: a picture, check out my selfie. // Example: You sent: great shot! private func buildAccessibilityLabel(componentView: CVComponentViewMessage) -> String { var elements = [String]() if isIncoming { if let accessibilityAuthorName = itemViewState.accessibilityAuthorName { let format = OWSLocalizedString( "CONVERSATION_VIEW_CELL_ACCESSIBILITY_SENDER_FORMAT", comment: "Format for sender info for accessibility label for message. Embeds {{ the sender name }}.", ) elements.append(String.nonPluralLocalizedStringWithFormat(format, accessibilityAuthorName)) } else { owsFailDebug("Missing accessibilityAuthorName.") } } else if isOutgoing { elements.append(OWSLocalizedString( "CONVERSATION_VIEW_CELL_ACCESSIBILITY_SENDER_LOCAL_USER", comment: "Format for sender info for outgoing messages.", )) } // Order matters. For example, body media should be before body text. let accessibilityComponentKeys: [CVComponentKey] = [ .bodyMedia, .bodyText, .quotedReply, .sticker, .viewOnce, .audioAttachment, .genericAttachment, .contactShare, .reactions, ] var contents = [String]() for key in accessibilityComponentKeys { if let subcomponent = self.subcomponent(forKey: key) { if let accessibilityComponent = subcomponent as? CVAccessibilityComponent { contents.append(accessibilityComponent.accessibilityDescription) } else { owsFailDebug("Invalid accessibilityComponent.") } } } let timestampText: String if let paymentStatus = componentState.paymentAttachment?.status { timestampText = CVComponentFooter.paymentMessageTimestampText( forInteraction: interaction, paymentState: paymentStatus, shouldUseLongFormat: true, ) } else { timestampText = CVComponentFooter.timestampText( forInteraction: interaction, shouldUseLongFormat: true, hasBodyAttachments: componentState.messageHasBodyAttachments, adminDeleteRecipientStates: nil, // TODO: get correct adminDeleteRecipientStates ) } contents.append(timestampText) if let footerAccessibilityLabel = standaloneFooter?.footerAccessibilityLabel { contents.append(footerAccessibilityLabel) } elements.append(contents.joined(separator: ", ")) // NOTE: In the interest of keeping the accessibility label short, // we do not include information that is usually presented in the // following components: // // * footer (disappearing message status). // We _do_ include time but not date. Dates are in the date headers. // * senderName // * senderAvatar // * quotedReply // * linkPreview // * bottomButtons // * sendFailureBadge let result = elements.joined(separator: " ") return result } private var hOuterStackConfig: CVStackViewConfig { let bottomInset = reactions != nil ? reactionsVProtrusion : 0 let cellLayoutMargins = UIEdgeInsets( top: 0, leading: conversationStyle.fullWidthGutterLeading, bottom: bottomInset, trailing: conversationStyle.fullWidthGutterTrailing, ) return CVStackViewConfig( axis: .horizontal, alignment: .fill, spacing: ConversationStyle.messageStackSpacing, layoutMargins: cellLayoutMargins, ) } private var hInnerStackConfig: 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) } /// - Returns: Bubble background color for the current message. /// /// This method checks for all the special cases when bubble for the current message should have a non-default background styling. /// Examples: remotely deleted message, sticker message etc. /// For messages that are not a special case value from `ConversationStyle` will be returned. private var bubbleChatColor: ColorOrGradientValue { if conversationStyle.hasWallpaper == false, wasRemotelyDeleted { return .solidColor(color: Theme.backgroundColor) } if isBubbleTransparent { return .transparent } return itemModel.conversationStyle.bubbleChatColor(isIncoming: isIncoming) } /// - Returns: Bubble stroke configuration for the current message. /// /// This method checks for all the special cases when stroke styling for current message's bubble should have non-default styling. /// For messages that are not a special case value from `ConversationStyle` will be returned. private var bubbleStroke: BubbleConfiguration.Stroke? { if conversationStyle.hasWallpaper == false, wasRemotelyDeleted { return BubbleConfiguration.Stroke(color: UIColor.Signal.transparentSeparator, width: 1) } if isBubbleTransparent { return nil } return itemModel.conversationStyle.bubbleStroke(isIncoming: isIncoming) } private static let measurementKey_hOuterStack = "CVComponentMessage.measurementKey_hOuterStack" private static let measurementKey_hInnerStack = "CVComponentMessage.measurementKey_hInnerStack" private static let measurementKey_contentStack = "CVComponentMessage.measurementKey_contentStack" private static let measurementKey_topFullWidthStackView = "CVComponentMessage.measurementKey_topFullWidthStackView" private static let measurementKey_topNestedStackView = "CVComponentMessage.measurementKey_topNestedStackView" private static let measurementKey_bottomFullWidthStackView = "CVComponentMessage.measurementKey_bottomFullWidthStackView" private static let measurementKey_bottomNestedShareStackView = "CVComponentMessage.measurementKey_bottomNestedShareStackView" private static let measurementKey_bottomNestedTextStackView = "CVComponentMessage.measurementKey_bottomNestedTextStackView" private static let measurementKey_reactions = "CVComponentMessage.measurementKey_reactions" private static let measurementKey_bottomNestedTextSpacing = "CVComponentMessage.measurementKey_bottomNestedTextSpacing" public func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize { owsAssertDebug(maxWidth > 0) let selectionViewWidth = ConversationStyle.selectionViewWidth let hOuterStackConfig = self.hOuterStackConfig var contentMaxWidth = maxWidth - hOuterStackConfig.layoutMargins.totalWidth contentMaxWidth -= ConversationStyle.messageDirectionSpacing if isShowingSelectionUI { contentMaxWidth -= selectionViewWidth + hOuterStackConfig.spacing } if !isIncoming, hasSendFailureBadge { contentMaxWidth -= sendFailureBadgeSize + hOuterStackConfig.spacing } if hasSenderAvatarLayout { // Sender avatar in groups. contentMaxWidth -= CGFloat(ConversationStyle.groupMessageAvatarSizeClass.diameter) + ConversationStyle.messageStackSpacing } owsAssertDebug(conversationStyle.maxMediaMessageWidth <= conversationStyle.maxMessageWidth) let shouldUseNarrowMaxWidth = ( bodyMedia != nil || linkPreview != nil, ) if shouldUseNarrowMaxWidth { contentMaxWidth = max(0, min(conversationStyle.maxMediaMessageWidth, contentMaxWidth)) } else { contentMaxWidth = max(0, min(conversationStyle.maxMessageWidth, contentMaxWidth)) } let contentStackSize = measureContentStack( maxWidth: contentMaxWidth, measurementBuilder: measurementBuilder, ) if contentStackSize.width > contentMaxWidth { owsFailDebug("contentStackSize: \(contentStackSize) > contentMaxWidth: \(contentMaxWidth)") } var hInnerStackSubviewInfos = [ManualStackSubviewInfo]() if hasSenderAvatarLayout, nil != self.senderAvatar { // Sender avatar in groups. let avatarSize = CGSize.square(CGFloat(ConversationStyle.groupMessageAvatarSizeClass.diameter)) hInnerStackSubviewInfos.append(avatarSize.asManualSubviewInfo(hasFixedSize: true)) } // NOTE: The contentStackSize does not have fixed width and may grow // to reflect the minBubbleWidth below. hInnerStackSubviewInfos.append(contentStackSize.asManualSubviewInfo) let hInnerStackMeasurement = ManualStackView.measure( config: hInnerStackConfig, measurementBuilder: measurementBuilder, measurementKey: Self.measurementKey_hInnerStack, subviewInfos: hInnerStackSubviewInfos, ) var hInnerStackSize = hInnerStackMeasurement.measuredSize let minBubbleWidth = Self.bubbleWideCornerRadius * 2 hInnerStackSize.width = max(hInnerStackSize.width, minBubbleWidth) var hOuterStackSubviewInfos = [ManualStackSubviewInfo]() if isShowingSelectionUI || wasShowingSelectionUI { let selectionViewSize = CGSize(width: selectionViewWidth, height: 0) hOuterStackSubviewInfos.append(selectionViewSize.asManualSubviewInfo(hasFixedWidth: true)) } if isOutgoing { // cellSpacer hOuterStackSubviewInfos.append(CGSize.zero.asManualSubviewInfo) } hOuterStackSubviewInfos.append(hInnerStackSize.asManualSubviewInfo(hasFixedWidth: true)) if isIncoming { // cellSpacer hOuterStackSubviewInfos.append(CGSize.zero.asManualSubviewInfo) } if !isIncoming, hasSendFailureBadge { let sendFailureBadgeSize = CGSize(square: sendFailureBadgeSize) hOuterStackSubviewInfos.append(sendFailureBadgeSize.asManualSubviewInfo(hasFixedWidth: true)) } let hOuterStackMeasurement = ManualStackView.measure( config: hOuterStackConfig, measurementBuilder: measurementBuilder, measurementKey: Self.measurementKey_hOuterStack, subviewInfos: hOuterStackSubviewInfos, maxWidth: maxWidth, ) if let reactionsSubcomponent = subcomponent(forKey: .reactions) { let reactionsSize = reactionsSubcomponent.measure( maxWidth: maxWidth, measurementBuilder: measurementBuilder, ) measurementBuilder.setSize(key: Self.measurementKey_reactions, size: reactionsSize) } return hOuterStackMeasurement.measuredSize } // The behavior of this method has to align exactly with that of configureContentStack(). private func measureContentStack( maxWidth contentMaxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder, ) -> CGSize { func measure( stackConfig: CVStackViewConfig, measurementKey: String, componentKeys keys: [CVComponentKey], ) -> CGSize { let maxWidth = contentMaxWidth - stackConfig.layoutMargins.totalWidth 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, ) if subviewSize.width > maxWidth { owsFailDebug("key: \(key), subviewSize: \(subviewSize) > maxWidth: \(maxWidth)") } subviewSizes.append(subviewSize) } var stackConfig = stackConfig func tryToOverlapBodyTextAndFooter() { guard keys == [.bodyText, .footer], let bodyText = self.bodyText as? CVComponentBodyText, let standaloneFooter = self.standaloneFooter, !standaloneFooter.tapForMoreState.shouldShowFooter else { return } guard let footerMeasurement = CVComponentFooter.footerMeasurement(measurementBuilder: measurementBuilder), let bodyTextMaxWidth = CVComponentBodyText.bodyTextMaxWidth(measurementBuilder: measurementBuilder), let bodyTextMeasurement = CVComponentBodyText.bodyTextMeasurement(measurementBuilder: measurementBuilder), let lastLineRect = bodyTextMeasurement.lastLineRect, lastLineRect.width > 0, lastLineRect.height > 0 else { owsFailDebug("Missing measurement state.") return } guard let bodyTextSubviewSize = subviewSizes.first, bodyTextSubviewSize == bodyTextMeasurement.size else { owsFailDebug("Invalid bodyTextSubviewSize.") return } let textMessageFont = bodyText.textMessageFont let lineHeight = max(0, textMessageFont.lineHeight) let capHeight = max(0, textMessageFont.capHeight) // NOTE: descender is expressed as a negative value. let descender = max(0, -textMessageFont.descender) // TODO: Design is finalizing how this value should scale with dynamic type. let spacingScaling = max(1, lineHeight / 20) let kMinimumOverlapSpacingDefault: CGFloat = 6 let minOverlapSpacing = kMinimumOverlapSpacingDefault * spacingScaling let isRTL = CurrentAppContext().isRTL let bodyTextSize = bodyTextSubviewSize.width let footerSize = footerMeasurement.measuredSize let overlappedLastLineWidth = ceil(lastLineRect.width) + minOverlapSpacing + footerSize.width let overlappedContentWidth = max(bodyTextSize, overlappedLastLineWidth) let hasSpaceForOverlap = overlappedContentWidth <= bodyTextMaxWidth guard hasSpaceForOverlap else { return } // Do collision detection to determine if footer and last line would // collide if overlapped. let isFooterAlignedLeft = isRTL var isBodyTextAlignedLeft = false let bodyTextLabelConfig = bodyText.buildBodyTextLabelConfig() // For body text messages, textAlignment should reflect the natural // alignment of the content. switch bodyTextLabelConfig.textAlignment { case .left: isBodyTextAlignedLeft = true case .right: isBodyTextAlignedLeft = false default: // This is expected for edge cases: oversize text messages, remotely deleted, etc. return } var detectionLastLineFrame = CGRect(origin: .zero, size: lastLineRect.size) if !isBodyTextAlignedLeft { detectionLastLineFrame.x = overlappedContentWidth - lastLineRect.width } // Simplify y-axis for purposes of collision detection. detectionLastLineFrame.y = 0 detectionLastLineFrame.height = 1 var detectionFooterFrame = CGRect(origin: .zero, size: footerSize) if !isFooterAlignedLeft { detectionFooterFrame.x = overlappedContentWidth - footerSize.width } // Inset one of the frames to account for the "min overlap spacing". detectionFooterFrame = detectionFooterFrame.insetBy(dx: -minOverlapSpacing, dy: 0) detectionFooterFrame.y = 0 detectionFooterFrame.height = 1 let doComponentsIntersect = detectionLastLineFrame.intersects(detectionFooterFrame) guard !doComponentsIntersect else { return } let fontOuterSpacing = max(0, lineHeight - (capHeight + descender)) * 0.5 let baselineSpacing = max(0, fontOuterSpacing + descender) // We want to v-align the center of the footer with the baseline of the // last line of body text. let overlapHeight = footerMeasurement.measuredSize.height * 0.5 + baselineSpacing let bottomNestedTextSpacing = -overlapHeight // 1. Rewrite the stack spacing for overlap. stackConfig = stackConfig.withSpacing(bottomNestedTextSpacing) // 2. Store the spacing for usage when rendering. measurementBuilder.setValue( key: Self.measurementKey_bottomNestedTextSpacing, value: bottomNestedTextSpacing, ) // 3. Rewrite the body text component size for overlap. subviewSizes[0] = CGSize( width: overlappedContentWidth, height: bodyTextSubviewSize.height, ) } tryToOverlapBodyTextAndFooter() let subviewInfos: [ManualStackSubviewInfo] = subviewSizes.map { subviewSize in subviewSize.asManualSubviewInfo } let stackMeasurement = ManualStackView.measure( config: stackConfig, measurementBuilder: measurementBuilder, measurementKey: measurementKey, subviewInfos: subviewInfos, ) return stackMeasurement.measuredSize } let stickerOverlaySubcomponent = subcomponent(forKey: .sticker) if nil != stickerOverlaySubcomponent { // Sticker message. // // Stack is borderless. // Optional footer. return measure( stackConfig: buildBorderlessStackConfig(), measurementKey: Self.measurementKey_contentStack, componentKeys: [.senderName, .sticker, .footer], ) } else { // The non-sticker case. // Use multiple stacks. let contentSections = buildContentSections() var subviewSizes = [CGSize]() enumerate(contentSections: contentSections) { (contentSection: ContentSection, stackConfig: CVStackViewConfig) in guard let stackMeasurementKey = contentSection.stackMeasurementKey else { owsFailDebug("Missing stackMeasurementKey.") return } let componentKeys = contentSection.components.map { $0.componentKey } let stackSize = measure( stackConfig: stackConfig, measurementKey: stackMeasurementKey, componentKeys: componentKeys, ) subviewSizes.append(stackSize) } // Append the bottom buttons if necessary. if let bottomButtons { let subviewSize = bottomButtons.measure( maxWidth: contentMaxWidth, measurementBuilder: measurementBuilder, ) subviewSizes.append(subviewSize) } if let bottomLabel { let subviewSize = bottomLabel.measure( maxWidth: contentMaxWidth, measurementBuilder: measurementBuilder, ) subviewSizes.append(subviewSize) } let subviewInfos: [ManualStackSubviewInfo] = subviewSizes.map { subviewSize in subviewSize.asManualSubviewInfo } return ManualStackView.measure( config: buildContentStackConfig(), measurementBuilder: measurementBuilder, measurementKey: Self.measurementKey_contentStack, subviewInfos: subviewInfos, ).measuredSize } } // MARK: - Events override public func cellWillBecomeVisible(componentDelegate: any CVComponentDelegate) { subcomponents(forKeys: CVComponentKey.allCases).forEach { subcomponent in subcomponent.cellWillBecomeVisible(componentDelegate: componentDelegate) } } override public func handleTap( sender: UIGestureRecognizer, componentDelegate: CVComponentDelegate, componentView: CVComponentView, renderItem: CVRenderItem, ) -> Bool { guard let componentView = componentView as? CVComponentViewMessage else { owsFailDebug("Unexpected componentView.") return false } if isShowingSelectionUI { // By default, use primarySelectionView to handle .allContent... let primarySelectionView = componentView.primarySelectionView var selectionView = primarySelectionView var selectionType: CVSelectionType = .allContent // ...but we might have separate "primary" and "secondary" selections. // "Primary" is "everything but body text" and "secondary" is "just body text". if hasSecondaryContentForSelection { let secondarySelectionView = componentView.secondarySelectionView func distanceToViewCenter(_ view: UIView) -> CGFloat { let tapLocation = sender.location(in: view) let viewCenter = view.bounds.center return tapLocation.distance(viewCenter) } let primaryDistance = distanceToViewCenter(primarySelectionView) let secondaryDistance = distanceToViewCenter(secondarySelectionView) if primaryDistance < secondaryDistance { selectionView = primarySelectionView selectionType = .primaryContent } else { selectionView = secondarySelectionView selectionType = .secondaryContent } } let itemViewModel = CVItemViewModelImpl(renderItem: renderItem) let selectionState = componentDelegate.selectionState if selectionState.isSelected(interaction.uniqueId, selectionType: selectionType) { selectionView.isSelected = false componentDelegate.selectionState.remove( itemViewModel: itemViewModel, selectionType: selectionType, ) } else { selectionView.isSelected = true componentDelegate.selectionState.add( itemViewModel: itemViewModel, selectionType: selectionType, ) } // Suppress other tap handling during selection mode. return true } if let outgoingMessage = interaction as? TSOutgoingMessage, !(outgoingMessage is OWSPaymentMessage) { switch outgoingMessage.messageState { case .failed: // Tap to retry. componentDelegate.didTapFailedMessage(outgoingMessage) return true case .pending: componentDelegate.didTapPendingOutgoingMessage(outgoingMessage) return true case .sending: // Sending messages should still allow taps to be processed break default: break } } if let message = interaction as? TSIncomingMessage, message.wasRemotelyDeleted { let db = DependenciesBridge.shared.db let recipientAddressStates = db.read { tx in AdminDeleteManager.recipientAddressStates(message: message, tx: tx) } if AdminDeleteManager.isFailedAdminDelete(recipientAddressStates: recipientAddressStates) { componentDelegate.didTapFailedMessage(message) return true } } if hasSenderAvatar, componentView.avatarView.containsGestureLocation(sender) { componentDelegate.didTapSenderAvatar(interaction) return true } for subcomponentAndView in findComponentAndViews(sender: sender, componentView: componentView) { let subcomponent = subcomponentAndView.component let subcomponentView = subcomponentAndView.componentView if subcomponent.handleTap( sender: sender, componentDelegate: componentDelegate, componentView: subcomponentView, renderItem: renderItem, ) { return true } } if let message = interaction as? TSMessage, componentState.skippedDownloads != nil { componentDelegate.didTapSkippedDownloads(message) return true } return false } override public func canHandleDoubleTap( sender: UIGestureRecognizer, componentDelegate: any CVComponentDelegate, renderItem: CVRenderItem, ) -> Bool { if isShowingSelectionUI { return false } let viewModel = CVItemViewModelImpl(renderItem: renderItem) return viewModel.canEditMessage } override public func handleDoubleTap(sender: UIGestureRecognizer, componentDelegate: any CVComponentDelegate, renderItem: CVRenderItem) -> Bool { guard canHandleDoubleTap(sender: sender, componentDelegate: componentDelegate, renderItem: renderItem) else { return false } let viewModel = CVItemViewModelImpl(renderItem: renderItem) componentDelegate.didDoubleTapTextViewItem(viewModel) return true } override public func findLongPressHandler( sender: UIGestureRecognizer, componentDelegate: CVComponentDelegate, componentView: CVComponentView, renderItem: CVRenderItem, ) -> CVLongPressHandler? { guard let componentView = componentView as? CVComponentViewMessage else { owsFailDebug("Unexpected componentView.") return nil } if let componentAndView = findActiveComponentAndView( key: .bodyText, messageView: componentView, ignoreMissing: true, ), let handler = componentAndView.component.findLongPressHandler( sender: sender, componentDelegate: componentDelegate, componentView: componentAndView.componentView, renderItem: renderItem, ) { return handler } let longPressKeys: [CVComponentKey: CVLongPressHandler.GestureLocation] = [ .sticker: .sticker, .bodyMedia: .media, .audioAttachment: .media, .genericAttachment: .media, .quotedReply: .quotedReply, .paymentAttachment: .paymentMessage, .archivedPaymentAttachment: .paymentMessage, .poll: .poll, // Bottom buttons, labels, and footers are associated // with other components and should not have unique long-press actions. .bottomButtons: .associatedSubcomponent, .bottomLabel: .associatedSubcomponent, .footer: .associatedSubcomponent, // TODO: linkPreview? ] // Recognize the correct message type when tapping next to the message itself let hotArea = UIEdgeInsets(hMargin: -.greatestFiniteMagnitude, vMargin: 0) for (key, gestureLocation) in longPressKeys { if let subcomponentView = componentView.subcomponentView(key: key), subcomponentView.rootView.containsGestureLocation(sender, hotAreaInsets: hotArea) { return CVLongPressHandler( delegate: componentDelegate, renderItem: renderItem, gestureLocation: gestureLocation, ) } } 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 findComponentAndViews( sender: UIGestureRecognizer, componentView: CVComponentViewMessage, ) -> [CVComponentAndView] { return activeComponentAndViews(messageView: componentView).compactMap { subcomponentAndView in 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 { owsFailDebug("Missing subcomponentView.") } return nil } return CVComponentAndView(key: key, component: subcomponent, componentView: subcomponentView) } public func albumItemView( forAttachment attachment: ReferencedAttachment, 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. public class CVComponentViewMessage: NSObject, CVComponentView { // Contains the cell contents which are arranged horizontally: // // * Gutters // * Message Selection UI // * hInnerStack // * "Send failure" badge fileprivate let hOuterStack = ManualStackView(name: "message.hOuterStack") // Contains the cell contents which are arranged horizontally: // // * Group sender avatar // * Content view wrapped in message bubble _or_ unwrapped content view. // // Additionally, it contains: // // * Reactions view, which uses a custom layout block. fileprivate let hInnerStack = ManualStackView(name: "message.hInnerStack") fileprivate let avatarView = ConversationAvatarView( sizeClass: ConversationStyle.groupMessageAvatarSizeClass, localUserDisplayMode: .asUser, useAutolayout: false, ) // This view provides background for outgoing messages in all scenarios // and for incoming messages when there's no chat wallpaper. fileprivate let chatColorView = CVColorOrGradientView() // This view provides background for incoming messages when // there is a chat wallpaper and bubble background is "blur". fileprivate var wallpaperBlurView: CVWallpaperBlurView? fileprivate func ensureWallpaperBlurView() -> CVWallpaperBlurView { if let wallpaperBlurView { return wallpaperBlurView } let wallpaperBlurView = CVWallpaperBlurView() self.wallpaperBlurView = wallpaperBlurView return wallpaperBlurView } // Contains the actual renderable message content, arranged vertically. fileprivate let contentStack = ManualStackView(name: "message.contentStack") // 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 = ManualStackView(name: "message.topFullWidthStackView") fileprivate let topNestedStackView = ManualStackView(name: "message.topNestedStackView") fileprivate let bottomFullWidthStackView = ManualStackView(name: "message.bottomFullWidthStackView") fileprivate let bottomNestedShareStackView = ManualStackView(name: "message.bottomNestedShareStackView") fileprivate let bottomNestedTextStackView = ManualStackView(name: "message.bottomNestedTextStackView") // If hasSecondaryContentForSelection is false, this is used to select // all content. // // If hasSecondaryContentForSelection is true, this is used to select // everything except the body text, e.g. the body media or generic attachment. fileprivate lazy var primarySelectionView = MessageSelectionView() // If hasSecondaryContentForSelection is true, this is used to select // just the body text. fileprivate lazy var secondarySelectionView = MessageSelectionView() fileprivate let selectionWrapper = ManualLayoutView(name: "message.selectionWrapper") fileprivate let swipeToReplyIconView = SwipeToReplyIndicatorView() fileprivate let cellSpacer = UIView() fileprivate let avatarViewSwipeToReplyWrapper = SwipeToReplyWrapper( name: "avatarViewSwipeToReplyWrapper", useSlowOffset: true, shouldReset: false, ) fileprivate let swipeToReplyIconSwipeToReplyWrapper = SwipeToReplyWrapper( name: "swipeToReplyIconSwipeToReplyWrapper", useSlowOffset: true, shouldReset: false, ) fileprivate var contentViewSwipeToReplyWrapper = SwipeToReplyWrapper( name: "contentViewSwipeToReplyWrapper", useSlowOffset: false, shouldReset: true, ) fileprivate var reactionsSwipeToReplyWrapper = SwipeToReplyWrapper( name: "reactionsSwipeToReplyWrapper", useSlowOffset: false, shouldReset: true, ) fileprivate var swipeToReplyWrappers: [SwipeToReplyWrapper] { [ avatarViewSwipeToReplyWrapper, swipeToReplyIconSwipeToReplyWrapper, contentViewSwipeToReplyWrapper, reactionsSwipeToReplyWrapper, ] } public var isDedicatedCellView = false public var rootView: UIView { hOuterStack } // 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 giftBadgeView: CVComponentView? var reactionsView: CVComponentView? var audioAttachmentView: CVComponentView? var genericAttachmentView: CVComponentView? var paymentAttachmentView: CVComponentView? var undownloadableAttachmentView: CVComponentView? var archivedPaymentView: CVComponentView? var contactShareView: CVComponentView? var bottomButtonsView: CVComponentView? var bottomLabelView: CVComponentView? var pollView: CVComponentView? private var allSubcomponentViews: [CVComponentView] { [ senderNameView, bodyTextView, bodyMediaView, footerView, stickerView, viewOnceView, quotedReplyView, linkPreviewView, giftBadgeView, reactionsView, audioAttachmentView, genericAttachmentView, paymentAttachmentView, undownloadableAttachmentView, archivedPaymentView, contactShareView, bottomButtonsView, bottomLabelView, pollView, ].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 .giftBadge: return giftBadgeView case .reactions: return reactionsView case .audioAttachment: return audioAttachmentView case .genericAttachment: return genericAttachmentView case .paymentAttachment: return paymentAttachmentView case .archivedPaymentAttachment: return archivedPaymentView case .undownloadableAttachment: return undownloadableAttachmentView case .contactShare: return contactShareView case .bottomButtons: return bottomButtonsView case .poll: return pollView case .bottomLabel: return bottomLabelView // We don't render sender avatars with a subcomponent. case .senderAvatar: owsFailDebug("Invalid component key: \(key)") return nil case .systemMessage, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .skippedDownloads, .sendFailureBadge, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet, .messageRoot: 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 .giftBadge: giftBadgeView = subcomponentView case .reactions: reactionsView = subcomponentView case .audioAttachment: audioAttachmentView = subcomponentView case .genericAttachment: genericAttachmentView = subcomponentView case .paymentAttachment: paymentAttachmentView = subcomponentView case .archivedPaymentAttachment: archivedPaymentView = subcomponentView case .undownloadableAttachment: undownloadableAttachmentView = subcomponentView case .contactShare: contactShareView = subcomponentView case .bottomButtons: bottomButtonsView = subcomponentView case .poll: pollView = subcomponentView case .bottomLabel: bottomLabelView = subcomponentView // We don't render sender avatars with a subcomponent. case .senderAvatar: owsAssertDebug(subcomponentView == nil) case .systemMessage, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .skippedDownloads, .sendFailureBadge, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet, .messageRoot: owsAssertDebug(subcomponentView == nil) } } // MARK: - override init() { chatColorView.layoutMargins = .zero chatColorView.ensureSubviewsFillBounds = true avatarViewSwipeToReplyWrapper.subview = avatarView swipeToReplyIconSwipeToReplyWrapper.subview = swipeToReplyIconView // Configure contentViewSwipeToReplyWrapper and // reactionsSwipeToReplyWrapper later. } public func setIsCellVisible(_ isCellVisible: Bool) { for subcomponentView in allSubcomponentViews { subcomponentView.setIsCellVisible(isCellVisible) } if isCellVisible { chatColorView.updateAppearance() } } public func setSwipeToReplyOffset( fastOffset: CGPoint, slowOffset: CGPoint, ) { for swipeToReplyWrapper in swipeToReplyWrappers { let offset = ( swipeToReplyWrapper.useSlowOffset ? slowOffset : fastOffset, ) swipeToReplyWrapper.offset = offset } } public func reset() { removeSwipeActionAnimations() if !isDedicatedCellView { hOuterStack.reset() hInnerStack.reset() contentStack.reset() topFullWidthStackView.reset() topNestedStackView.reset() bottomFullWidthStackView.reset() bottomNestedShareStackView.reset() bottomNestedTextStackView.reset() for swipeToReplyWrapper in swipeToReplyWrappers { if swipeToReplyWrapper.shouldReset { swipeToReplyWrapper.reset() } else { swipeToReplyWrapper.offset = .zero } } } selectionWrapper.reset() contentStack.removeFromSuperview() chatColorView.removeFromSuperview() chatColorView.reset() wallpaperBlurView?.removeFromSuperview() avatarView.reset() swipeToReplyIconView.alpha = 0 // We use hInnerStack.frame to detect whether or not // the cell has been laid out yet. Therefore we clear it here. hInnerStack.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) } } } } public func canHandleDoubleTapGesture(_ sender: UIGestureRecognizer) -> Bool { // If we have a body text view, allow taps anywhere (incl. adjacent whitespace). return bodyTextView != nil } public func contextMenuContentView() -> UIView? { chatColorView.animationsEnabled = true return contentViewSwipeToReplyWrapper } public func contextMenuAuxiliaryContentView() -> UIView? { reactionsSwipeToReplyWrapper } public func contextMenuPresentationWillBegin() { avatarView.isHidden = true } public func contextMenuPresentationDidEnd() { avatarView.isHidden = false chatColorView.animationsEnabled = false } fileprivate func removeSwipeActionAnimations() { for swipeToReplyWrapper in swipeToReplyWrappers { swipeToReplyWrapper.layer.removeAllAnimations() } } // MARK: - Flashing Message Bubble func performMessageBubbleHighlightAnimation() { var dimmableBubbleView: CVDimmableView? if let wallpaperBlurView, wallpaperBlurView.superview != nil { dimmableBubbleView = wallpaperBlurView } else if chatColorView.superview != nil { dimmableBubbleView = chatColorView } guard let dimmableBubbleView else { return } dimmableBubbleView.dimmerColor = Theme.isDarkThemeEnabled ? .ows_whiteAlpha25 : .ows_blackAlpha25 dimmableBubbleView.performDimmingAnimation(animationDuration: 0.4, dimDuration: 0.8) } } // MARK: - Swipe To Reply override public func findPanHandler( sender: UIPanGestureRecognizer, componentDelegate: CVComponentDelegate, componentView: CVComponentView, renderItem: CVRenderItem, messageSwipeActionState: CVMessageSwipeActionState, ) -> 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, messageSwipeActionState: messageSwipeActionState, ) { return panHandler } return CVPanHandler( delegate: componentDelegate, panType: .messageSwipeAction, renderItem: renderItem, ) } override public func startPanGesture( sender: UIPanGestureRecognizer, panHandler: CVPanHandler, componentDelegate: CVComponentDelegate, componentView: CVComponentView, renderItem: CVRenderItem, messageSwipeActionState: CVMessageSwipeActionState, ) { 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, messageSwipeActionState: messageSwipeActionState, ) case .messageSwipeAction: updateSwipeActionProgress( sender: sender, panHandler: panHandler, componentDelegate: componentDelegate, renderItem: renderItem, componentView: componentView, messageSwipeActionState: messageSwipeActionState, hasFinished: false, ) tryToApplySwipeAction(componentView: componentView) } } override public func handlePanGesture( sender: UIPanGestureRecognizer, panHandler: CVPanHandler, componentDelegate: CVComponentDelegate, componentView: CVComponentView, renderItem: CVRenderItem, messageSwipeActionState: CVMessageSwipeActionState, ) { 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, messageSwipeActionState: messageSwipeActionState, ) case .messageSwipeAction: let hasFinished: Bool switch sender.state { case .changed: hasFinished = false case .ended: hasFinished = true default: clearSwipeAction( componentView: componentView, renderItem: renderItem, messageSwipeActionState: messageSwipeActionState, isAnimated: false, ) return } updateSwipeActionProgress( sender: sender, panHandler: panHandler, componentDelegate: componentDelegate, renderItem: renderItem, componentView: componentView, messageSwipeActionState: messageSwipeActionState, hasFinished: hasFinished, ) tryToApplySwipeAction(componentView: componentView) if sender.state == .ended { clearSwipeAction( componentView: componentView, renderItem: renderItem, messageSwipeActionState: messageSwipeActionState, isAnimated: true, ) } } } override public func contextMenuAccessoryViews(componentView: CVComponentView) -> [ContextMenuTargetedPreviewAccessory]? { if hasSenderAvatar { guard let componentView = componentView as? CVComponentViewMessage else { owsFailDebug("Unexpected componentView.") return nil } let avatarView = ConversationAvatarView(sizeClass: componentView.avatarView.configuration.sizeClass, localUserDisplayMode: .asUser) avatarView.updateWithSneakyTransactionIfNecessary { newConfig in newConfig = componentView.avatarView.configuration } avatarView.frame = componentView.avatarView.bounds let isRTL = CurrentAppContext().isRTL let horizontalEdgeAlignment: ContextMenuTargetedPreviewAccessory.AccessoryAlignment.Edge = isRTL ? .trailing : .leading let alignment = ContextMenuTargetedPreviewAccessory.AccessoryAlignment(alignments: [(horizontalEdgeAlignment, .exterior), (.bottom, .interior)], alignmentOffset: CGPoint(x: 8, y: 0)) let avatarViewAccessory = ContextMenuTargetedPreviewAccessory(accessoryView: avatarView, accessoryAlignment: alignment) avatarViewAccessory.animateAccessoryPresentationAlongsidePreview = true return [avatarViewAccessory] } else { return nil } } private let swipeActionOffsetThreshold: CGFloat = 55 private func updateSwipeActionProgress( sender: UIPanGestureRecognizer, panHandler: CVPanHandler, componentDelegate: CVComponentDelegate, renderItem: CVRenderItem, componentView: CVComponentViewMessage, messageSwipeActionState: CVMessageSwipeActionState, hasFinished: Bool, ) { AssertIsOnMainThread() var xOffset = sender.translation(in: componentView.rootView).x var xVelocity = sender.velocity(in: componentView.rootView).x // Invert positions for RTL logic, since the user is swiping in the opposite direction. if CurrentAppContext().isRTL { xOffset = -xOffset xVelocity = -xVelocity } let hasFailed = [.failed, .cancelled].contains(sender.state) let storedOffset = (hasFailed || hasFinished) ? 0 : xOffset let progress = CVMessageSwipeActionState.Progress(xOffset: storedOffset) messageSwipeActionState.setProgress( interactionId: renderItem.interactionUniqueId, progress: progress, ) self.swipeActionProgress = progress let swipeToReplyIconView = componentView.swipeToReplyIconView let swipeToReplyIconWrapper = componentView.swipeToReplyIconSwipeToReplyWrapper let previousActiveDirection = panHandler.activeDirection let activeDirection: CVPanHandler.ActiveDirection switch xOffset { case let x where x >= swipeActionOffsetThreshold: // We're doing a message swipe action. We should // only become active if this message allows // swipe-to-reply. let itemViewModel = CVItemViewModelImpl(renderItem: renderItem) if componentDelegate.shouldAllowMessageSendActionsForItem(itemViewModel) { activeDirection = .right } else { activeDirection = .none } case let x where x <= -swipeActionOffsetThreshold: activeDirection = .left default: activeDirection = .none } let didChangeActiveDirection = previousActiveDirection != activeDirection panHandler.activeDirection = activeDirection // Play a haptic when moving to active. if didChangeActiveDirection { switch activeDirection { case .right: ImpactHapticFeedback.impactOccurred(style: .light) panHandler.percentDrivenTransition?.cancel() panHandler.percentDrivenTransition = nil case .left: ImpactHapticFeedback.impactOccurred(style: .light) panHandler.percentDrivenTransition = UIPercentDrivenInteractiveTransition() componentDelegate.didTapShowMessageDetail(CVItemViewModelImpl(renderItem: renderItem)) case .none: panHandler.percentDrivenTransition?.cancel() panHandler.percentDrivenTransition = nil } } // Update the reply image styling to reflect active state let isStarting = sender.state == .began if isStarting { // Prepare the message detail view as soon as we start doing // any gesture, we may or may not want to present it. componentDelegate.prepareMessageDetailForInteractivePresentation(CVItemViewModelImpl(renderItem: renderItem)) } if isStarting || didChangeActiveDirection { let shouldAnimate = didChangeActiveDirection let transform: CGAffineTransform let tintColor: UIColor if activeDirection == .right { transform = CGAffineTransform(scaleX: 1.16, y: 1.16) tintColor = conversationStyle.bubbleTextColorIncoming } else { transform = .identity tintColor = conversationStyle.bubbleTextColorIncoming.withAlphaComponent(0.5) } swipeToReplyIconWrapper.layer.removeAllAnimations() swipeToReplyIconView.tintColor = tintColor if shouldAnimate { UIView.animate( withDuration: 0.2, delay: 0, usingSpringWithDamping: 0.06, initialSpringVelocity: 0.8, options: [.curveEaseInOut, .beginFromCurrentState], animations: { swipeToReplyIconWrapper.transform = transform }, completion: nil, ) } else { swipeToReplyIconWrapper.transform = transform } } if hasFinished { switch activeDirection { case .left: guard let percentDrivenTransition = panHandler.percentDrivenTransition else { return owsFailDebug("Missing percentDrivenTransition") } // Only finish the pan if we're actively moving in // the correct direction. if xVelocity <= 0 { percentDrivenTransition.finish() } else { percentDrivenTransition.cancel() } case .right: let itemViewModel = CVItemViewModelImpl(renderItem: renderItem) componentDelegate.didTapReplyToItem(itemViewModel) case .none: break } } else if activeDirection == .left { guard let percentDrivenTransition = panHandler.percentDrivenTransition else { return owsFailDebug("Missing percentDrivenTransition") } let viewXOffset = sender.translation(in: componentDelegate.view).x let percentDriventTransitionProgress = (abs(viewXOffset) - swipeActionOffsetThreshold) / (componentDelegate.view.width - swipeActionOffsetThreshold) percentDrivenTransition.update(percentDriventTransitionProgress) } } private func tryToApplySwipeAction(componentView: CVComponentViewMessage) { AssertIsOnMainThread() guard let swipeActionProgress else { return } guard !wasRemotelyDeleted else { return } let swipeToReplyIconView = componentView.swipeToReplyIconView // Scale the translation above or below the desired range, // to produce an elastic feeling when you overscroll. var alpha = swipeActionProgress.xOffset let isSwipingLeft = alpha < 0 if isSwipingLeft, alpha < -swipeActionOffsetThreshold { // If we're swiping left, stop moving the message // after we reach the threshold. alpha = -swipeActionOffsetThreshold } else if alpha > swipeActionOffsetThreshold { let overflow = alpha - swipeActionOffsetThreshold alpha = swipeActionOffsetThreshold + overflow / 4 } let position = CurrentAppContext().isRTL ? -alpha : alpha let slowPosition: CGFloat if isSwipingLeft { slowPosition = position } else { // When swiping right (swipe-to-reply) the swipe content moves at // 1/8th the speed of the message bubble, so that it reveals itself // from underneath with an elastic feel. slowPosition = position / 8 } var iconAlpha: CGFloat = 1 let useSwipeFadeTransition = isBorderless if useSwipeFadeTransition { iconAlpha = CGFloat.inverseLerp(alpha, min: 0, max: swipeActionOffsetThreshold).clamp01() } componentView.removeSwipeActionAnimations() swipeToReplyIconView.alpha = iconAlpha componentView.setSwipeToReplyOffset( fastOffset: CGPoint(x: position, y: 0), slowOffset: CGPoint(x: slowPosition, y: 0), ) } private func clearSwipeAction( componentView: CVComponentViewMessage, renderItem: CVRenderItem, messageSwipeActionState: CVMessageSwipeActionState, isAnimated: Bool, ) { AssertIsOnMainThread() messageSwipeActionState.resetProgress(interactionId: renderItem.interactionUniqueId) let iconView = componentView.swipeToReplyIconView let animations = { componentView.setSwipeToReplyOffset(fastOffset: .zero, slowOffset: .zero) iconView.alpha = 0 } if isAnimated { UIView.animate(withDuration: 0.2, animations: animations) } else { componentView.removeSwipeActionAnimations() animations() } self.swipeActionProgress = nil } } // MARK: - private 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, ) return subcomponentView } else { let subcomponentView = subcomponent.buildComponentView(componentDelegate: componentDelegate) messageView.setSubcomponentView(key: key, subcomponentView: subcomponentView) subcomponent.configureForRendering( componentView: subcomponentView, cellMeasurement: cellMeasurement, componentDelegate: componentDelegate, ) 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) } private enum ContentStackMargin { case none case topMargin case bottomMargin case spacingDefault case spacingCustom(spacing: CGFloat) } private func buildNestedStackConfig( topMargin: ContentStackMargin, bottomMargin: ContentStackMargin, ) -> CVStackViewConfig { func marginValue(_ margin: ContentStackMargin) -> CGFloat { switch margin { case .none: return 0 case .topMargin: return conversationStyle.textInsetTop case .bottomMargin: return conversationStyle.textInsetBottom case .spacingDefault: return Self.textViewVSpacing case .spacingCustom(let spacing): return spacing } } var layoutMargins = conversationStyle.textInsets layoutMargins.top = marginValue(topMargin) layoutMargins.bottom = marginValue(bottomMargin) return CVStackViewConfig( axis: .vertical, alignment: .fill, spacing: Self.textViewVSpacing, layoutMargins: layoutMargins, ) } func buildBorderlessStackConfig() -> CVStackViewConfig { buildNoMarginsStackConfig() } func buildFullWidthStackConfig(includeTopMargin: Bool, includeBottomMargin: Bool) -> CVStackViewConfig { var layoutMargins = UIEdgeInsets.zero if includeTopMargin { layoutMargins.top = conversationStyle.textInsets.top } if includeBottomMargin { layoutMargins.bottom = conversationStyle.textInsets.bottom } return CVStackViewConfig( axis: .vertical, alignment: .fill, spacing: Self.textViewVSpacing, layoutMargins: layoutMargins, ) } func buildNoMarginsStackConfig() -> CVStackViewConfig { CVStackViewConfig( axis: .vertical, alignment: .fill, spacing: Self.textViewVSpacing, layoutMargins: .zero, ) } func buildContentStackConfig() -> CVStackViewConfig { CVStackViewConfig( axis: .vertical, alignment: .fill, spacing: 0, layoutMargins: .zero, ) } func configureSubcomponentStack( messageView: CVComponentViewMessage, stackView: ManualStackView, stackConfig: CVStackViewConfig, cellMeasurement: CVCellMeasurement, measurementKey: String, componentDelegate: CVComponentDelegate, keys: [CVComponentKey], ) { let subviews: [UIView] = keys.compactMap { key in // TODO: configureSubcomponent should probably just return the componentView. guard let componentAndView = configureSubcomponent( messageView: messageView, cellMeasurement: cellMeasurement, componentDelegate: componentDelegate, key: key, ) else { return nil } return componentAndView.componentView.rootView } stackView.reset() stackView.configure( config: stackConfig, cellMeasurement: cellMeasurement, measurementKey: measurementKey, subviews: subviews, ) } 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 } } // MARK: - class SwipeToReplyWrapper: ManualLayoutView { var offset: CGPoint = .zero { didSet { layoutSubviews() } } var subview: UIView? { didSet { // This view should only be configured after being reset. owsAssertDebug((subview == nil) || (oldValue == nil)) oldValue?.removeFromSuperview() if let subview { owsAssertDebug(subview.superview == nil) addSubview(subview) layoutSubviews() } } } let useSlowOffset: Bool let shouldReset: Bool init( name: String, useSlowOffset: Bool, shouldReset: Bool, ) { self.useSlowOffset = useSlowOffset self.shouldReset = shouldReset super.init(name: name) addDefaultLayoutBlock() } private func addDefaultLayoutBlock() { addLayoutBlock { view in guard let view = view as? SwipeToReplyWrapper else { owsFailDebug("Invalid reference view.") return } guard let subview = view.subview else { return } var subviewFrame = view.bounds subviewFrame.origin += view.offset ManualLayoutView.setSubviewFrame(subview: subview, frame: subviewFrame) } } override func reset() { super.reset() subview = nil offset = .zero addDefaultLayoutBlock() } } private class SwipeToReplyIndicatorView: UIView { var backgroundEffect: UIVisualEffect? = nil { didSet { // Show/hide background. let imageName: String if let backgroundEffect { if let backgroundView { backgroundView.effect = backgroundEffect backgroundView.isHidden = false } else { let blurEffectView = UIVisualEffectView(effect: backgroundEffect) blurEffectView.clipsToBounds = true if #available(iOS 26, *) { blurEffectView.cornerConfiguration = .capsule() } insertSubview(blurEffectView, at: 0) self.backgroundView = blurEffectView } backgroundView?.contentView.addSubview(imageView) // Smaller icon when we there is a background. imageName = "reply-20" } else { backgroundView?.effect = nil backgroundView?.isHidden = true addSubview(imageView) // Larger - 24 dp - icon. imageName = "reply" } // Update icon: imageView.image = UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate) invalidateIntrinsicContentSize() setNeedsLayout() } } private let imageView = UIImageView() private var backgroundView: UIVisualEffectView? init() { super.init(frame: .zero) imageView.contentMode = .center addSubview(imageView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override var intrinsicContentSize: CGSize { let size: CGFloat = (backgroundView?.isHidden ?? true) ? 34 : 24 return .square(size) } override func layoutSubviews() { super.layoutSubviews() imageView.frame = bounds if let backgroundView { backgroundView.frame = bounds if #unavailable(iOS 26) { backgroundView.layer.cornerRadius = min(bounds.height, bounds.width) / 2 } } } }