Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/ConversationView/Components/CVComponentThreadDetails.swift
1 views
//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import Lottie
import SignalServiceKit
public import SignalUI

public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {

    public var componentKey: CVComponentKey { .threadDetails }

    public var cellReuseIdentifier: CVCellReuseIdentifier {
        CVCellReuseIdentifier.threadDetails
    }

    public let isDedicatedCell = false

    private let threadDetails: CVComponentState.ThreadDetails

    private var avatarDataSource: ConversationAvatarDataSource? { threadDetails.avatarDataSource }
    private var titleText: String { threadDetails.titleText }
    private var groupDescriptionText: String? { threadDetails.groupDescriptionText }

    private var canTapTitle: Bool {
        thread is TSContactThread && !thread.isNoteToSelf
    }

    init(itemModel: CVItemModel, threadDetails: CVComponentState.ThreadDetails) {
        self.threadDetails = threadDetails

        super.init(itemModel: itemModel)
    }

    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,
        )
    }

    public func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView {
        CVComponentViewThreadDetails()
    }

    override public func wallpaperBlurView(componentView: CVComponentView) -> CVWallpaperBlurView? {
        guard let componentView = componentView as? CVComponentViewThreadDetails else {
            owsFailDebug("Unexpected componentView.")
            return nil
        }
        return componentView.wallpaperBlurView
    }

    public func configureForRendering(
        componentView componentViewParam: CVComponentView,
        cellMeasurement: CVCellMeasurement,
        componentDelegate: CVComponentDelegate,
    ) {
        guard let componentView = componentViewParam as? CVComponentViewThreadDetails else {
            owsFailDebug("Unexpected componentView.")
            componentViewParam.reset()
            return
        }

        let outerStackView = componentView.outerStackView
        let innerStackView = componentView.innerStackView

        innerStackView.reset()
        outerStackView.reset()

        outerStackView.insetsLayoutMarginsFromSafeArea = false
        innerStackView.insetsLayoutMarginsFromSafeArea = false

        var innerViews = [UIView]()

        let avatarView = ConversationAvatarView(sizeClass: avatarSizeClass, localUserDisplayMode: .asUser, useAutolayout: false)
        avatarView.updateWithSneakyTransactionIfNecessary { configuration in
            configuration.dataSource = avatarDataSource
        }
        componentView.avatarView = avatarView
        if threadDetails.isAvatarBlurred {
            let avatarWrapper = ManualLayoutView(name: "avatarWrapper")
            avatarWrapper.addSubviewToFillSuperviewEdges(avatarView)
            innerViews.append(avatarWrapper)

            var unblurAvatarSubviewInfos = [ManualStackSubviewInfo]()
            let subviews: [UIView]

            if threadDetails.isAvatarBeingDownloaded {
                let lottieView = LottieAnimationView(name: "indeterminate_spinner_44")
                lottieView.loopMode = .loop
                lottieView.play()
                unblurAvatarSubviewInfos.append(CGSize.square(44).asManualSubviewInfo(hasFixedSize: true))

                subviews = [lottieView]
            } else {
                let unblurAvatarIconView = CVImageView()
                unblurAvatarIconView.setTemplateImageName("tap-outline-24", tintColor: .ows_white)
                unblurAvatarSubviewInfos.append(CGSize.square(24).asManualSubviewInfo(hasFixedSize: true))

                let unblurAvatarLabelConfig = CVLabelConfig.unstyledText(
                    OWSLocalizedString(
                        "THREAD_DETAILS_TAP_TO_UNBLUR_AVATAR",
                        comment: "Indicator that a blurred avatar can be revealed by tapping.",
                    ),
                    font: UIFont.dynamicTypeSubheadlineClamped,
                    textColor: .ows_white,
                )
                let maxWidth = CGFloat(avatarSizeClass.diameter) - 12
                let unblurAvatarLabelSize = CVText.measureLabel(
                    config: unblurAvatarLabelConfig,
                    maxWidth: maxWidth,
                )
                unblurAvatarSubviewInfos.append(unblurAvatarLabelSize.asManualSubviewInfo)
                let unblurAvatarLabel = CVLabel()
                unblurAvatarLabelConfig.applyForRendering(label: unblurAvatarLabel)
                subviews = [unblurAvatarIconView, unblurAvatarLabel]
            }

            let unblurAvatarStackConfig = ManualStackView.Config(
                axis: .vertical,
                alignment: .center,
                spacing: 8,
                layoutMargins: .zero,
            )
            let unblurAvatarStackMeasurement = ManualStackView.measure(
                config: unblurAvatarStackConfig,
                subviewInfos: unblurAvatarSubviewInfos,
            )

            let unblurAvatarStack = ManualStackView(name: "unblurAvatarStack")
            unblurAvatarStack.configure(
                config: unblurAvatarStackConfig,
                measurement: unblurAvatarStackMeasurement,
                subviews: subviews,
            )
            avatarWrapper.addSubviewToCenterOnSuperview(
                unblurAvatarStack,
                size: unblurAvatarStackMeasurement.measuredSize,
            )
        } else {
            innerViews.append(avatarView)
        }
        innerViews.append(UIView.spacer(withHeight: vSpacingTitle))

        let titleButton = componentView.titleButton
        titleLabelConfig.applyForRendering(button: titleButton)
        self.configureTitleAction(button: titleButton, delegate: componentDelegate)
        innerViews.append(titleButton)

        let detailsButton = componentView.detailsButton
        let mutualGroupsLabel = componentView.mutualGroupsLabel
        let showTipsButton = componentView.showTipsButton
        let nameNotVerifiedButton = componentView.profileNamesEducationButton

        let groupInfoWrapper = ManualLayoutViewWithLayer(name: "groupWrapper")

        if let safetySection = threadDetails.safetySection {
            let reduceTransparency = UIAccessibility.isReduceTransparencyEnabled

            groupInfoWrapper.layer.cornerRadius = 40
            if conversationStyle.hasWallpaper {
                if reduceTransparency {
                    groupInfoWrapper.backgroundColor = isDarkThemeEnabled ? .black : .white
                } else {
                    groupInfoWrapper.backgroundColor = isDarkThemeEnabled ? .black.withAlphaComponent(0.3) : .white.withAlphaComponent(0.6)
                }
                groupInfoWrapper.layer.borderWidth = 0.5
                groupInfoWrapper.layer.borderColor = isDarkThemeEnabled ? UIColor.white.withAlphaComponent(0.1).cgColor : UIColor.black.withAlphaComponent(0.1).cgColor
            } else {
                groupInfoWrapper.layer.borderWidth = 2
                groupInfoWrapper.backgroundColor = .clear
                groupInfoWrapper.layer.borderColor = UIColor.Signal.tertiaryFill.cgColor
            }

            if safetySection.shouldShowProfileNamesEducation {
                innerViews.append(UIView.spacer(withHeight: vSpacingNotVerifiedLabel))

                let nameNotVerifiedButtonLabelConfig = nameNotVerifiedConfig()
                nameNotVerifiedButtonLabelConfig.applyForRendering(button: nameNotVerifiedButton)
                nameNotVerifiedButton.backgroundColor = UIColor.Signal.warningLabel.withAlphaComponent(0.2)
                nameNotVerifiedButton.ows_contentEdgeInsets = .init(hMargin: hPaddingNotVerifiedButton, vMargin: vPaddingNotVerifiedButton)
                nameNotVerifiedButton.dimsWhenHighlighted = true
                nameNotVerifiedButton.block = {
                    componentDelegate.didTapNameEducation(type: safetySection.threadType)
                }
                innerViews.append(nameNotVerifiedButton)
            }

            if let groupDescriptionText = self.groupDescriptionText {
                innerViews.append(UIView.spacer(withHeight: vSpacingSafetySectionDefault))
                let groupDescriptionPreviewView = componentView.groupDescriptionPreviewView
                let config = groupDescriptionTextLabelConfig(text: groupDescriptionText)
                groupDescriptionPreviewView.apply(config: config)
                groupDescriptionPreviewView.groupName = titleText
                innerViews.append(groupDescriptionPreviewView)
            }

            if let detailsText = safetySection.detailsText {
                innerViews.append(UIView.spacer(withHeight: vSpacingSafetySectionDefault))
                innerViews.append(detailsButton)
                let config = mutualGroupsLabelConfig(attributedText: detailsText)
                config.applyForRendering(button: detailsButton)
                // Tap to see member count
                if safetySection.threadType == .group {
                    detailsButton.block = { [weak componentDelegate] in
                        componentDelegate?.didTapShowConversationSettings()
                    }
                }
            }

            if let mutualGroupsText = safetySection.mutualGroupsText {
                innerViews.append(UIView.spacer(withHeight: vSpacingSafetySectionDefault))
                let mutualGroupsLabelConfig = mutualGroupsLabelConfig(attributedText: mutualGroupsText)
                mutualGroupsLabelConfig.applyForRendering(label: mutualGroupsLabel)
                innerViews.append(mutualGroupsLabel)
            }

            if safetySection.shouldShowSafetyTipsButton {
                innerViews.append(UIView.spacer(withHeight: vSpacingSafetyButton))
                innerViews.append(showTipsButton)
                let safetyButtonLabelConfig = safetyTipsConfig()
                safetyButtonLabelConfig.applyForRendering(button: showTipsButton)
                showTipsButton.ows_contentEdgeInsets = .init(hMargin: hPaddingSafetyButton, vMargin: vPaddingSafetyButton)
                showTipsButton.dimsWhenHighlighted = true
                showTipsButton.block = { [weak componentDelegate] in
                    componentDelegate?.didTapSafetyTips()
                }

                if conversationStyle.hasWallpaper {
                    if isDarkThemeEnabled {
                        showTipsButton.backgroundColor = UIColor.white.withAlphaComponent(0.2)
                    } else {
                        showTipsButton.backgroundColor = UIColor.black.withAlphaComponent(0.12)
                    }
                } else {
                    showTipsButton.backgroundColor = UIColor.Signal.secondaryFill
                }
            }
        } else {
            innerViews.append(UIView.spacer(withHeight: minBottomPadding))
        }

        innerStackView.configure(
            config: innerStackConfig,
            cellMeasurement: cellMeasurement,
            measurementKey: Self.measurementKey_innerStack,
            subviews: innerViews,
        )

        let groupInfoView = ManualLayoutView(name: "groupInfoView")
        groupInfoView.addSubview(groupInfoWrapper)

        groupInfoView.addSubviewToCenterOnSuperviewWithDesiredSize(innerStackView)
        groupInfoView.addLayoutBlock({ [weak self] _ in
            guard let self, let superview = groupInfoWrapper.superview else {
                return
            }

            let outlineViewWidth = innerStackView.frame.width + hPaddingGroupDetails * 2

            let adjustedContainerSize = CGSize(
                width: outlineViewWidth,
                height: superview.bounds.height - vOffsetThreadDetailsOutline,
            )

            let originShift = (superview.width - outlineViewWidth) / 2

            let subviewFrame = CGRect(
                origin: CGPoint(x: originShift, y: superview.bounds.origin.y + vOffsetThreadDetailsOutline),
                size: adjustedContainerSize,
            )

            ManualLayoutView.setSubviewFrame(subview: groupInfoWrapper, frame: subviewFrame)
        })

        let outerViews = [groupInfoView]
        outerStackView.configure(
            config: outerStackConfig,
            cellMeasurement: cellMeasurement,
            measurementKey: Self.measurementKey_outerStack,
            subviews: outerViews,
        )
    }

    private var titleLabelConfig: CVLabelConfig {
        let font = UIFont.dynamicTypeTitle3.semibold()
        let textColor = Theme.primaryTextColor
        let attributedString = NSMutableAttributedString(string: titleText, attributes: [
            .font: font,
            .foregroundColor: textColor,
        ])

        if threadDetails.shouldShowVerifiedBadge {
            attributedString.append(" ")
            let verifiedBadgeImage = Theme.iconImage(.official)
            let verifiedBadgeAttachment = NSAttributedString.with(
                image: verifiedBadgeImage,
                font: .dynamicTypeTitle3,
                centerVerticallyRelativeTo: font,
                heightReference: .pointSize,
            )
            attributedString.append(verifiedBadgeAttachment)
        }

        if canTapTitle {
            attributedString.append(
                SignalSymbol.chevronTrailing(for: titleText).attributedString(
                    dynamicTypeBaseSize: 20,
                    leadingCharacter: .nonBreakingSpace,
                    attributes: [.foregroundColor: UIColor.Signal.secondaryLabel],
                ),
            )
        }

        return CVLabelConfig(
            text: .attributedText(attributedString),
            displayConfig: .forUnstyledText(font: font, textColor: textColor),
            font: font,
            textColor: textColor,
            numberOfLines: 0,
            lineBreakMode: .byWordWrapping,
            textAlignment: .center,
        )
    }

    private func configureTitleAction(
        button: OWSButton,
        delegate: CVComponentDelegate?,
    ) {
        guard
            canTapTitle,
            let contactThread = thread as? TSContactThread
        else {
            button.isEnabled = false
            button.dimsWhenHighlighted = false
            button.block = {}
            return
        }

        button.dimsWhenHighlighted = true
        button.block = { [weak delegate] in
            delegate?.didTapContactName(thread: contactThread)
        }
        button.isEnabled = true
    }

    private static var mutualGroupsFont: UIFont { .dynamicTypeSubheadline }
    private static var mutualGroupsTextColor: UIColor { Theme.primaryTextColor }

    private static var underlineColor: UIColor { UIColor.Signal.transparentSeparator }

    private func mutualGroupsLabelConfig(attributedText: NSAttributedString) -> CVLabelConfig {
        CVLabelConfig(
            text: .attributedText(attributedText),
            displayConfig: .forUnstyledText(
                font: Self.mutualGroupsFont,
                textColor: Self.mutualGroupsTextColor,
            ),
            font: Self.mutualGroupsFont,
            textColor: Self.mutualGroupsTextColor,
            numberOfLines: 0,
            lineBreakMode: .byWordWrapping,
            textAlignment: .center,
        )
    }

    private func nameNotVerifiedConfig() -> CVLabelConfig {
        let symbol = SignalSymbol.personQuestion.attributedString(dynamicTypeBaseSize: UIFont.dynamicTypeCalloutClamped.pointSize)
        let notVerifiedString = NSAttributedString.composed(
            of: [
                symbol,
                SignalSymbol.LeadingCharacter.space.rawValue,
                OWSLocalizedString(
                    "THREAD_DETAILS_PROFILE_NAMES_ARE_NOT_VERIFIED_SUBJECT",
                    comment: "Label displayed below profiles",
                ),
            ],
        )
        return CVLabelConfig(
            text: .attributedText(notVerifiedString),
            displayConfig: .forUnstyledText(
                font: .dynamicTypeCallout.medium(),
                textColor: UIColor.Signal.warningLabel,
            ),
            font: .dynamicTypeCallout.medium(),
            textColor: UIColor.Signal.warningLabel,
            numberOfLines: 0,
            lineBreakMode: .byWordWrapping,
        )
    }

    private func safetyTipsConfig() -> CVLabelConfig {
        CVLabelConfig.unstyledText(
            OWSLocalizedString(
                "SAFETY_TIPS_BUTTON_ACTION_TITLE",
                comment: "Title for Safety Tips button in thread details.",
            ),
            font: UIFont.dynamicTypeSubheadline.semibold(),
            textColor: Theme.isDarkThemeEnabled ? .ows_white : .ows_black,
        )
    }

    private func groupDescriptionTextLabelConfig(text: String) -> CVLabelConfig {
        CVLabelConfig.unstyledText(
            text,
            font: .dynamicTypeSubheadline,
            textColor: Theme.primaryTextColor,
            numberOfLines: 2,
            lineBreakMode: .byTruncatingTail,
            textAlignment: .center,
        )
    }

    private static let avatarSizeClass = ConversationAvatarView.Configuration.SizeClass.seventyFour
    private var avatarSizeClass: ConversationAvatarView.Configuration.SizeClass { Self.avatarSizeClass }

    static func buildComponentState(
        thread: TSThread,
        transaction: DBReadTransaction,
        avatarBuilder: CVAvatarBuilder,
    ) -> CVComponentState.ThreadDetails {
        if let contactThread = thread as? TSContactThread {
            return buildComponentState(
                contactThread: contactThread,
                transaction: transaction,
                avatarBuilder: avatarBuilder,
            )
        } else if let groupThread = thread as? TSGroupThread {
            return buildComponentState(
                groupThread: groupThread,
                transaction: transaction,
                avatarBuilder: avatarBuilder,
            )
        } else {
            owsFailDebug("Invalid thread.")
            return CVComponentState.ThreadDetails(
                avatarDataSource: nil,
                isAvatarBlurred: false,
                isAvatarBeingDownloaded: false,
                titleText: TSGroupThread.defaultGroupName,
                shouldShowVerifiedBadge: false,
                safetySection: nil,
                groupDescriptionText: nil,
            )
        }
    }

    private static func buildComponentState(
        contactThread: TSContactThread,
        transaction: DBReadTransaction,
        avatarBuilder: CVAvatarBuilder,
    ) -> CVComponentState.ThreadDetails {

        let avatarDataSource = avatarBuilder.buildAvatarDataSource(
            forAddress: contactThread.contactAddress,
            includingBadge: true,
            localUserDisplayMode: .noteToSelf,
            diameterPoints: avatarSizeClass.diameter,
        )

        let contactManager = SSKEnvironment.shared.contactManagerImplRef
        let isAvatarBlurred = contactManager.shouldBlurContactAvatar(
            address: contactThread.contactAddress,
            tx: transaction,
        )
        let isAvatarBeingDownloaded = contactManager.avatarAddressesToShowDownloadingSpinner.contains(contactThread.contactAddress)

        let displayName = SSKEnvironment.shared.contactManagerRef.displayName(
            for: contactThread.contactAddress,
            tx: transaction,
        )

        let titleText = { () -> String in
            if contactThread.isNoteToSelf {
                return MessageStrings.noteToSelf
            } else {
                return displayName.resolvedValue()
            }
        }()

        let shouldShowVerifiedBadge = contactThread.isNoteToSelf

        let safetySection = Self.buildContactSafetySection(
            for: displayName,
            in: contactThread,
            tx: transaction,
        )

        return CVComponentState.ThreadDetails(
            avatarDataSource: avatarDataSource,
            isAvatarBlurred: isAvatarBlurred,
            isAvatarBeingDownloaded: isAvatarBeingDownloaded,
            titleText: titleText,
            shouldShowVerifiedBadge: shouldShowVerifiedBadge,
            safetySection: safetySection,
            groupDescriptionText: nil,
        )
    }

    private static func buildComponentState(
        groupThread: TSGroupThread,
        transaction: DBReadTransaction,
        avatarBuilder: CVAvatarBuilder,
    ) -> CVComponentState.ThreadDetails {
        // If we need to reload this cell to reflect changes to any of the
        // state captured here, we need update the didThreadDetailsChange().

        let avatarDataSource = avatarBuilder.buildAvatarDataSource(
            forGroupThread: groupThread,
            diameterPoints: avatarSizeClass.diameter,
        )

        let contactManager = SSKEnvironment.shared.contactManagerImplRef
        let isAvatarBlurred = contactManager.shouldBlurGroupAvatar(
            groupId: groupThread.groupId,
            tx: transaction,
        )
        let isAvatarBeingDownloaded = contactManager.avatarGroupIdsToShowDownloadingSpinner.contains(groupThread.groupId)

        let titleText = groupThread.groupNameOrDefault

        let safetySection = Self.buildGroupsSafetySection(
            from: groupThread,
            tx: transaction,
        )
        let descriptionText: String? = {
            guard let groupModelV2 = groupThread.groupModel as? TSGroupModelV2 else { return nil }
            return groupModelV2.descriptionText
        }()

        return CVComponentState.ThreadDetails(
            avatarDataSource: avatarDataSource,
            isAvatarBlurred: isAvatarBlurred,
            isAvatarBeingDownloaded: isAvatarBeingDownloaded,
            titleText: titleText,
            shouldShowVerifiedBadge: false,
            safetySection: safetySection,
            groupDescriptionText: descriptionText,
        )
    }

    private let vSpacingTitle: CGFloat = 8
    private let vSpacingNotVerifiedLabel: CGFloat = 6
    private let vSpacingSafetyButton: CGFloat = 16
    private let vSpacingSafetySectionDefault: CGFloat = 8

    private let vPaddingSafetyButton: CGFloat = 5
    private let hPaddingSafetyButton: CGFloat = 12
    private let hPaddingSafetySection: CGFloat = 30

    private let vPaddingNotVerifiedButton: CGFloat = 2
    private let hPaddingNotVerifiedButton: CGFloat = 12

    private let hPaddingGroupDetails: CGFloat = 40

    private let vOffsetThreadDetailsOutline: CGFloat = 16

    private let minBottomPadding: CGFloat = 4

    private var outerStackConfig: CVStackViewConfig {
        CVStackViewConfig(
            axis: .vertical,
            alignment: .fill,
            spacing: 0,
            layoutMargins: UIEdgeInsets(top: 8, left: 32, bottom: 16, right: 32),
        )
    }

    private var innerStackConfig: CVStackViewConfig {
        CVStackViewConfig(
            axis: .vertical,
            alignment: .center,
            spacing: 0,
            layoutMargins: UIEdgeInsets(top: 0, left: 0, bottom: 24, right: 0),
        )
    }

    private static let measurementKey_outerStack = "CVComponentThreadDetails.measurementKey_outerStack"
    private static let measurementKey_innerStack = "CVComponentThreadDetails.measurementKey_innerStack"

    public func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
        owsAssertDebug(maxWidth > 0)

        var innerSubviewInfos = [ManualStackSubviewInfo]()

        let maxContentWidth = maxWidth - (
            outerStackConfig.layoutMargins.totalWidth +
                innerStackConfig.layoutMargins.totalWidth + (hPaddingSafetySection * 2)
        )

        innerSubviewInfos.append(avatarSizeClass.size.asManualSubviewInfo)
        innerSubviewInfos.append(CGSize(square: vSpacingTitle).asManualSubviewInfo)

        let titleSize = CVText.measureLabel(config: titleLabelConfig, maxWidth: maxContentWidth)
        innerSubviewInfos.append(titleSize.asManualSubviewInfo)

        let maxGroupWidth = maxContentWidth

        if let safetySection = threadDetails.safetySection {
            if safetySection.shouldShowProfileNamesEducation {
                innerSubviewInfos.append(CGSize(square: vSpacingNotVerifiedLabel).asManualSubviewInfo)
                let notVerifiedSize = CVText.measureLabel(
                    config: nameNotVerifiedConfig(),
                    maxWidth: maxGroupWidth,
                )
                let notVerifiedSizeWithPadding = CGSize(width: notVerifiedSize.width + hPaddingNotVerifiedButton * 2, height: notVerifiedSize.height + vPaddingNotVerifiedButton * 2)
                innerSubviewInfos.append(notVerifiedSizeWithPadding.asManualSubviewInfo)
            }

            if let groupDescriptionText = self.groupDescriptionText {
                innerSubviewInfos.append(CGSize(square: vSpacingSafetySectionDefault).asManualSubviewInfo)
                var groupDescriptionSize = CVText.measureLabel(
                    config: groupDescriptionTextLabelConfig(text: groupDescriptionText),
                    maxWidth: maxContentWidth,
                )
                groupDescriptionSize.width = maxContentWidth
                innerSubviewInfos.append(groupDescriptionSize.asManualSubviewInfo(hasFixedWidth: true))
            }

            if let detailsText = safetySection.detailsText {
                innerSubviewInfos.append(CGSize(square: vSpacingSafetySectionDefault).asManualSubviewInfo)
                let size = CVText.measureLabel(
                    config: mutualGroupsLabelConfig(attributedText: detailsText),
                    maxWidth: maxGroupWidth,
                )
                innerSubviewInfos.append(size.asManualSubviewInfo)
            }

            if let mutualGroupsText = safetySection.mutualGroupsText {
                innerSubviewInfos.append(CGSize(square: vSpacingSafetySectionDefault).asManualSubviewInfo)
                let groupLabelSize = CVText.measureLabel(
                    config: mutualGroupsLabelConfig(attributedText: mutualGroupsText),
                    maxWidth: maxGroupWidth,
                )
                innerSubviewInfos.append(groupLabelSize.asManualSubviewInfo)
            }

            if safetySection.shouldShowSafetyTipsButton {
                innerSubviewInfos.append(CGSize(square: vSpacingSafetyButton).asManualSubviewInfo)
                let safetyTipSize = CVText.measureLabel(
                    config: safetyTipsConfig(),
                    maxWidth: maxGroupWidth,
                )
                let safetyTipSizeWithPadding = CGSize(width: safetyTipSize.width + hPaddingSafetyButton * 2, height: safetyTipSize.height + vPaddingSafetyButton * 2)
                innerSubviewInfos.append(safetyTipSizeWithPadding.asManualSubviewInfo)
            }
        } else {
            innerSubviewInfos.append(CGSize(square: minBottomPadding).asManualSubviewInfo)
        }

        let innerStackMeasurement = ManualStackView.measure(
            config: innerStackConfig,
            measurementBuilder: measurementBuilder,
            measurementKey: Self.measurementKey_innerStack,
            subviewInfos: innerSubviewInfos,
        )
        let outerSubviewInfos = [innerStackMeasurement.measuredSize.asManualSubviewInfo]
        let outerStackMeasurement = ManualStackView.measure(
            config: outerStackConfig,
            measurementBuilder: measurementBuilder,
            measurementKey: Self.measurementKey_outerStack,
            subviewInfos: outerSubviewInfos,
            maxWidth: maxWidth,
        )
        return outerStackMeasurement.measuredSize
    }

    // MARK: - Events

    override public func handleTap(
        sender: UIGestureRecognizer,
        componentDelegate: CVComponentDelegate,
        componentView: CVComponentView,
        renderItem: CVRenderItem,
    ) -> Bool {
        guard let componentView = componentView as? CVComponentViewThreadDetails else {
            owsFailDebug("Unexpected componentView.")
            return false
        }

        if
            canTapTitle,
            let contactThread = thread as? TSContactThread,
            componentView.titleButton.bounds.contains(sender.location(in: componentView.titleButton))
        {
            componentDelegate.didTapContactName(thread: contactThread)
            return true
        }

        if let safetySection = threadDetails.safetySection {
            if
                safetySection.shouldShowSafetyTipsButton,
                componentView.showTipsButton.bounds.contains(sender.location(in: componentView.showTipsButton))
            {
                componentDelegate.didTapSafetyTips()
                return true
            }

            if
                safetySection.threadType == .group,
                safetySection.detailsText != nil,
                componentView.detailsButton.bounds.contains(sender.location(in: componentView.detailsButton))
            {
                componentDelegate.didTapShowConversationSettings()
                return true
            }

            if
                safetySection.shouldShowProfileNamesEducation,
                componentView.profileNamesEducationButton.bounds.contains(sender.location(in: componentView.profileNamesEducationButton))
            {
                componentDelegate.didTapNameEducation(type: safetySection.threadType)
                return true
            }
        }

        if threadDetails.isAvatarBlurred {
            guard let avatarView = componentView.avatarView else {
                owsFailDebug("Missing avatarView.")
                return false
            }

            let location = sender.location(in: avatarView)
            if avatarView.bounds.contains(location) {
                let contactManager = SSKEnvironment.shared.contactManagerImplRef
                contactManager.didTapToUnblurAvatar(for: thread)
                return true
            }
        }
        return false
    }

    // MARK: -

    // Used for rendering some portion of an Conversation View item.
    // It could be the entire item or some part thereof.
    public class CVComponentViewThreadDetails: NSObject, CVComponentView {

        fileprivate var avatarView: ConversationAvatarView?

        fileprivate let titleLabel = CVLabel()
        fileprivate let titleButton = CVButton()
        fileprivate let bioLabel = CVLabel()

        fileprivate let profileNamesEducationButton = OWSRoundedButton()

        fileprivate let reviewCarefullyLabel = CVLabel()
        fileprivate let detailsButton = CVButton()
        fileprivate let mutualGroupsLabel = CVLabel()
        fileprivate let showTipsButton = OWSRoundedButton()
        fileprivate let groupDescriptionPreviewView = GroupDescriptionPreviewView(
            shouldDeactivateConstraints: true,
        )

        fileprivate let outerStackView = ManualStackView(name: "Thread details outer")
        fileprivate let innerStackView = ManualStackView(name: "Thread details inner")

        fileprivate var wallpaperBlurView: CVWallpaperBlurView?
        fileprivate func ensureWallpaperBlurView() -> CVWallpaperBlurView {
            if let wallpaperBlurView = self.wallpaperBlurView {
                return wallpaperBlurView
            }
            let wallpaperBlurView = CVWallpaperBlurView()
            self.wallpaperBlurView = wallpaperBlurView
            return wallpaperBlurView
        }

        public var isDedicatedCellView = false

        public var rootView: UIView {
            outerStackView
        }

        // MARK: -

        public func setIsCellVisible(_ isCellVisible: Bool) {}

        public func reset() {
            outerStackView.reset()
            innerStackView.reset()
            innerStackView.removeFromSuperview()

            titleLabel.text = nil
            titleButton.reset()
            bioLabel.text = nil
            reviewCarefullyLabel.text = nil
            detailsButton.reset()
            mutualGroupsLabel.text = nil
            groupDescriptionPreviewView.descriptionText = nil
            avatarView = nil

            wallpaperBlurView?.removeFromSuperview()
        }

    }
}

extension CVComponentThreadDetails {
    private static func buildGroupsSafetySection(
        from groupThread: TSGroupThread,
        tx: DBReadTransaction,
    ) -> CVComponentState.ThreadDetails.SafetySection {
        let accountManager = DependenciesBridge.shared.tsAccountManager

        let groupMembership = groupThread.groupModel.groupMembership
        var members = groupMembership.fullMembers

        let localUserIsAMember: Bool
        if let localIdentifiers = accountManager.localIdentifiers(tx: tx) {
            // Remove yourself because we don't want to show your display name
            let removedMember = members.remove(localIdentifiers.aciAddress)
            localUserIsAMember = removedMember != nil
        } else {
            localUserIsAMember = false
        }

        let sortedMemberNames = SSKEnvironment.shared.contactManagerImplRef
            .sortedComparableNames(for: members, tx: tx)
            .map { $0.displayName.resolvedValue() }

        let formatString: String
        var underlinedPortion: String?
        var arguments: [String] = sortedMemberNames
        switch (sortedMemberNames.count, localUserIsAMember) {
        case (0, _):
            formatString = OWSLocalizedString(
                "THREAD_DETAILS_NO_MEMBERS",
                comment: "Label for a group with no members or no members but yourself",
            )
        case (1, false):
            formatString = OWSLocalizedString(
                "THREAD_DETAILS_ONE_MEMBER",
                comment: "Label for a group with one member (not counting yourself), displaying their name",
            )
        case (1, true):
            formatString = OWSLocalizedString(
                "THREAD_DETAILS_ONE_MEMBER_AND_YOURSELF",
                comment: "Label for a group you are in with one other member, listing their name and yourself",
            )
        case (2, false):
            formatString = OWSLocalizedString(
                "THREAD_DETAILS_TWO_MEMBERS",
                comment: "Label for a group you are not in which has two members, listing their names",
            )
        case (2, true):
            formatString = OWSLocalizedString(
                "THREAD_DETAILS_TWO_MEMBERS_AND_YOURSELF",
                comment: "Label for a group you are in which has two other members, listing their names and yourself",
            )
        case (3, false):
            formatString = OWSLocalizedString(
                "THREAD_DETAILS_THREE_MEMBERS",
                comment: "Label for a group you are not in which has three members, listing their names",
            )
        case (3, true):
            formatString = OWSLocalizedString(
                "THREAD_DETAILS_THREE_MEMBERS_AND_YOURSELF",
                comment: "Label for a group you are in which has three other members, listing their names and yourself",
            )
        case (4, false):
            formatString = OWSLocalizedString(
                "THREAD_DETAILS_FOUR_MEMBERS",
                comment: "Label for a group you are not in which has four members, listing their names",
            )
        default:
            formatString = OWSLocalizedString(
                "THREAD_DETAILS_MANY_MEMBERS",
                comment: "Label for a group with more than four members, listing the first three members' names and embedding THREAD_DETAILS_OTHER_MEMBERS_COUNT_%ld as a count of other members",
            )

            let otherMembersFormat = OWSLocalizedString(
                "THREAD_DETAILS_OTHER_MEMBERS_COUNT_%ld",
                tableName: "PluralAware",
                comment: "The number of other members in a group. Embedded into the last parameter of THREAD_DETAILS_MANY_MEMBERS",
            )

            let firstThreeMembers = Array(arguments.prefix(3))
            let remainingMembersCount = sortedMemberNames.count + (localUserIsAMember ? 1 : 0) - firstThreeMembers.count

            let otherMembersString = String.localizedStringWithFormat(otherMembersFormat, remainingMembersCount)

            underlinedPortion = otherMembersString
            arguments = firstThreeMembers + [otherMembersString]
        }

        let membersString = String.nonPluralLocalizedStringWithFormat(
            formatString,
            arguments: arguments,
        )
        let membersAttributedString: NSAttributedString
        if let underlinedPortion {
            let underlinedRange = NSString(string: membersString).range(of: underlinedPortion)
            let attributedString = NSMutableAttributedString(string: membersString)
            attributedString.addAttributes(
                [
                    .underlineStyle: NSUnderlineStyle.single.rawValue,
                    .underlineColor: Self.underlineColor,
                ],
                range: underlinedRange,
            )
            membersAttributedString = attributedString
        } else {
            membersAttributedString = NSAttributedString(string: membersString)
        }

        let membersAttributedText = NSAttributedString.composed(of: [
            NSAttributedString.with(
                image: UIImage(named: "group-resizable")!,
                font: Self.mutualGroupsFont,
            ),
            "  ",
            membersAttributedString,
        ]).styled(
            with: .font(Self.mutualGroupsFont),
            .color(Self.mutualGroupsTextColor),
        )

        let shouldShowUnknownThreadWarning = SSKEnvironment.shared.contactManagerImplRef.isLowTrustGroup(groupThread: groupThread, tx: tx)

        return .init(
            shouldShowProfileNamesEducation: shouldShowUnknownThreadWarning,
            detailsText: membersAttributedText,
            mutualGroupsText: nil,
            threadType: .group,
            shouldShowSafetyTipsButton: shouldShowUnknownThreadWarning && groupThread.hasPendingMessageRequest(transaction: tx),
        )
    }

    private static func buildContactSafetySection(
        for displayName: DisplayName,
        in contactThread: TSContactThread,
        tx: DBReadTransaction,
    ) -> CVComponentState.ThreadDetails.SafetySection? {
        switch displayName {
        case .nickname, .systemContactName, .profileName:
            break
        case .phoneNumber, .username, .deletedAccount, .unknown:
            // If the display name is a phone number or username, you started a
            // conversation with them and don't yet have a profile name, so we
            // don't need to show name-related info.
            return nil
        }

        guard !contactThread.isNoteToSelf else {
            return .init(
                shouldShowProfileNamesEducation: false,
                detailsText: nil,
                mutualGroupsText: OWSLocalizedString(
                    "THREAD_DETAILS_NOTE_TO_SELF_EXPLANATION",
                    comment: "Subtitle appearing at the top of the users 'note to self' conversation",
                ).styled(
                    with: .font(.dynamicTypeSubheadline),
                    .color(UIColor.Signal.label),
                ),
                threadType: .contact,
                shouldShowSafetyTipsButton: false,
            )
        }

        let groupThreads = TSGroupThread.groupThreads(with: contactThread.contactAddress, transaction: tx)
        let mutualGroupNames = groupThreads.filter { $0.groupModel.groupMembership.isLocalUserFullMember && $0.shouldThreadBeVisible && !$0.isTerminatedGroup }.map { $0.groupNameOrDefault }

        let isMessageRequest = contactThread.hasPendingMessageRequest(transaction: tx)

        let groupNamesFormatArg: [String] = mutualGroupNames
        let formattedString: String
        switch mutualGroupNames.count {
        case 0:
            formattedString = String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "THREAD_DETAILS_ZERO_MUTUAL_GROUPS",
                    comment: "A string indicating there are no mutual groups the user shares with this contact",
                ),
                arguments: groupNamesFormatArg,
            )
        case 1:
            formattedString = String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "THREAD_DETAILS_ONE_MUTUAL_GROUP",
                    comment: "A string indicating a mutual group the user shares with this contact. Embeds {{mutual group name}}",
                ),
                arguments: groupNamesFormatArg,
            )
        case 2:
            formattedString = String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "THREAD_DETAILS_TWO_MUTUAL_GROUP",
                    comment: "A string indicating two mutual groups the user shares with this contact. Embeds {{mutual group name}}",
                ),
                arguments: groupNamesFormatArg,
            )
        case 3:
            formattedString = String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "THREAD_DETAILS_THREE_MUTUAL_GROUP",
                    comment: "A string indicating three mutual groups the user shares with this contact. Embeds {{mutual group name}}",
                ),
                arguments: groupNamesFormatArg,
            )
        default:
            // For this string, we want to use the first two groups' names
            // and add a final format arg for the number of remaining
            // groups.
            let firstTwoGroups = Array(mutualGroupNames[0..<2])
            let remainingGroupsCount = mutualGroupNames.count - firstTwoGroups.count
            let formatArgs: [CVarArg] = firstTwoGroups + [remainingGroupsCount]
            formattedString = String.localizedStringWithFormat(
                OWSLocalizedString(
                    "THREAD_DETAILS_MORE_MUTUAL_GROUP_%3$ld",
                    tableName: "PluralAware",
                    comment: "A string indicating two mutual groups the user shares with this contact and that there are more unlisted. Embeds {{group name, group name, number of other groups}}",
                ),
                formatArgs,
            )
        }

        // In order for the phone number to appear in the same box as the
        // mutual groups, it needs to be part of the same label.
        let phoneNumberString: NSAttributedString? = {
            if case .phoneNumber = displayName {
                return nil
            }
            let phoneNumber = contactThread.contactAddress.phoneNumber
            let formattedPhoneNumber = phoneNumber.map(PhoneNumber.bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber(_:))
            guard let formattedPhoneNumber else {
                return nil
            }
            return NSAttributedString.composed(of: [
                NSAttributedString.with(image: Theme.iconImage(.contactInfoPhone), font: Self.mutualGroupsFont),
                "  ",
                formattedPhoneNumber,
            ])
        }()

        let isSystemContact = SSKEnvironment.shared.contactManagerRef.fetchSignalAccount(for: contactThread.contactAddress, transaction: tx) != nil
        let shouldShowProfileNamesEducation: Bool
        if isMessageRequest {
            shouldShowProfileNamesEducation = true
        } else if case .nickname = displayName {
            shouldShowProfileNamesEducation = false
        } else if isSystemContact {
            shouldShowProfileNamesEducation = false
        } else {
            shouldShowProfileNamesEducation = true
        }

        return .init(
            shouldShowProfileNamesEducation: shouldShowProfileNamesEducation,
            detailsText: phoneNumberString,
            mutualGroupsText: NSAttributedString.composed(of: [
                NSAttributedString.with(
                    image: UIImage(named: "group-resizable")!,
                    font: Self.mutualGroupsFont,
                ),
                "  ",
                formattedString,
            ]),
            threadType: .contact,
            shouldShowSafetyTipsButton: isMessageRequest,
        )
    }
}