Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/ThreadSettings/ConversationHeaderBuilder.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import SignalServiceKit
import SignalUI
public import UIKit

@MainActor
struct ConversationHeaderBuilder {
    weak var delegate: ConversationHeaderDelegate!
    let transaction: DBReadTransaction
    let sizeClass: ConversationAvatarView.Configuration.SizeClass
    let options: Options

    var subviews = [UIView]()

    struct Options: OptionSet {
        let rawValue: Int

        static let message = Options(rawValue: 1 << 0)
        static let audioCall = Options(rawValue: 1 << 1)
        static let videoCall = Options(rawValue: 1 << 2)
        static let mute = Options(rawValue: 1 << 3)
        static let search = Options(rawValue: 1 << 4)

        static let renderLocalUserAsNoteToSelf = Options(rawValue: 1 << 5)
        static let noBackground = Options(rawValue: 1 << 6)
    }

    static func buildHeader(
        for thread: TSThread,
        sizeClass: ConversationAvatarView.Configuration.SizeClass,
        options: Options,
        memberLabel: MemberLabelForRendering?,
        delegate: ConversationHeaderDelegate,
    ) -> UIView {
        if let groupThread = thread as? TSGroupThread {
            return ConversationHeaderBuilder.buildHeaderForGroup(
                groupThread: groupThread,
                sizeClass: sizeClass,
                options: options,
                delegate: delegate,
            )
        } else if let contactThread = thread as? TSContactThread {
            return ConversationHeaderBuilder.buildHeaderForContact(
                contactThread: contactThread,
                sizeClass: sizeClass,
                options: options,
                memberLabel: memberLabel,
                delegate: delegate,
            )
        } else {
            owsFailDebug("Invalid thread.")
            return UIView()
        }
    }

    static func buildHeaderForGroup(
        groupThread: TSGroupThread,
        sizeClass: ConversationAvatarView.Configuration.SizeClass,
        options: Options,
        delegate: ConversationHeaderDelegate,
    ) -> UIView {
        // Make sure the view is loaded before we open a transaction,
        // because it can end up creating a transaction within.
        _ = delegate.view
        return SSKEnvironment.shared.databaseStorageRef.read { transaction in
            self.buildHeaderForGroup(
                groupThread: groupThread,
                sizeClass: sizeClass,
                options: options,
                delegate: delegate,
                transaction: transaction,
            )
        }
    }

    static func buildHeaderForGroup(
        groupThread: TSGroupThread,
        sizeClass: ConversationAvatarView.Configuration.SizeClass,
        options: Options,
        delegate: ConversationHeaderDelegate,
        transaction: DBReadTransaction,
    ) -> UIView {

        var isTerminated = false
        if let groupModelV2 = groupThread.groupModel as? TSGroupModelV2 {
            if groupModelV2.isTerminated {
                isTerminated = true
            }
        }

        var builder = ConversationHeaderBuilder(
            delegate: delegate,
            sizeClass: sizeClass,
            options: options,
            isTerminatedGroup: isTerminated,
            transaction: transaction,
        )

        var isShowingGroupDescription = false
        if let groupModel = groupThread.groupModel as? TSGroupModelV2 {
            if let descriptionText = groupModel.descriptionText {
                isShowingGroupDescription = true
                builder.addGroupDescriptionPreview(text: descriptionText)
            } else if delegate.canEditConversationAttributes, !groupModel.isTerminated {
                isShowingGroupDescription = true
                builder.addCreateGroupDescriptionButton()
            }
        }

        if !isShowingGroupDescription, !groupThread.groupModel.isPlaceholder {
            let memberCount = groupThread.groupModel.groupMembership.fullMembers.count
            var groupMembersText = GroupViewUtils.formatGroupMembersLabel(memberCount: memberCount, isTerminated: groupThread.isTerminatedGroup)
            if groupThread.isGroupV1Thread {
                groupMembersText.append(" ")
                groupMembersText.append("•")
                groupMembersText.append(" ")
                groupMembersText.append(OWSLocalizedString(
                    "GROUPS_LEGACY_GROUP_INDICATOR",
                    comment: "Label indicating a legacy group.",
                ))
            }
            builder.addSubtitleLabel(text: groupMembersText)
        }

        if groupThread.isGroupV1Thread {
            builder.addLegacyGroupView()
        }

        builder.addButtons()

        return builder.build()
    }

    static func buildHeaderForContact(
        contactThread: TSContactThread,
        sizeClass: ConversationAvatarView.Configuration.SizeClass,
        options: Options,
        memberLabel: MemberLabelForRendering?,
        delegate: ConversationHeaderDelegate,
    ) -> UIView {
        // Make sure the view is loaded before we open a transaction,
        // because it can end up creating a transaction within.
        _ = delegate.view
        return SSKEnvironment.shared.databaseStorageRef.read { transaction in
            self.buildHeaderForContact(
                contactThread: contactThread,
                sizeClass: sizeClass,
                options: options,
                memberLabel: memberLabel,
                delegate: delegate,
                transaction: transaction,
            )
        }
    }

    static func buildHeaderForContact(
        contactThread: TSContactThread,
        sizeClass: ConversationAvatarView.Configuration.SizeClass,
        options: Options,
        memberLabel: MemberLabelForRendering?,
        delegate: ConversationHeaderDelegate,
        transaction: DBReadTransaction,
    ) -> UIView {
        var builder = ConversationHeaderBuilder(
            delegate: delegate,
            sizeClass: sizeClass,
            options: options,
            isTerminatedGroup: false,
            transaction: transaction,
        )

        if BuildFlags.MemberLabel.display, let memberLabel {
            let memberLabelLabel = builder.addMemberLabel(label: memberLabel.label, color: memberLabel.groupNameColor)
            memberLabelLabel.numberOfLines = 0
            memberLabelLabel.textAlignment = .center
        }

        let address = contactThread.contactAddress
        if !address.isLocalAddress, let bioText = SSKEnvironment.shared.profileManagerRef.userProfile(for: address, tx: transaction)?.bioForDisplay {
            let label = builder.addSubtitleLabel(text: bioText)
            label.numberOfLines = 0
            label.lineBreakMode = .byWordWrapping
            label.textAlignment = .center
        }

        let recipientAddress = contactThread.contactAddress

        let identityManager = DependenciesBridge.shared.identityManager
        let isVerified = identityManager.verificationState(for: recipientAddress, tx: transaction) == .verified
        if isVerified {
            let subtitle = NSMutableAttributedString()
            subtitle.append(SignalSymbol.safetyNumber.attributedString(for: .subheadline, clamped: true))
            subtitle.append(" ")
            subtitle.append(SafetyNumberStrings.verified)
            builder.addSubtitleLabel(attributedText: subtitle)
        }

        builder.addButtons()

        return builder.build()
    }

    init(
        delegate: ConversationHeaderDelegate,
        sizeClass: ConversationAvatarView.Configuration.SizeClass,
        options: Options,
        isTerminatedGroup: Bool,
        transaction: DBReadTransaction,
    ) {

        self.delegate = delegate
        self.sizeClass = sizeClass
        self.options = options
        self.transaction = transaction

        addFirstSubviews(isTerminatedGroup: isTerminatedGroup, transaction: transaction)
    }

    mutating func addFirstSubviews(isTerminatedGroup: Bool, transaction: DBReadTransaction) {
        let avatarView = buildAvatarView(transaction: transaction)

        let avatarWrapper = UIView.container()
        avatarWrapper.addSubview(avatarView)
        avatarView.autoPinEdgesToSuperviewEdges()

        subviews.append(avatarWrapper)
        subviews.append(UIView.spacer(withHeight: 8))
        subviews.append(buildThreadNameLabel())

        if isTerminatedGroup {
            subviews.append(buildGroupTerminatedBanner())
            subviews.append(UIView.spacer(withHeight: 8))
        }
    }

    mutating func addButtons() {
        var buttons = [UIView]()

        if options.contains(.message) {
            buttons.append(buildIconButton(
                icon: .settingsChats,
                title: OWSLocalizedString(
                    "CONVERSATION_SETTINGS_MESSAGE_BUTTON",
                    comment: "Button to message the chat",
                ),
                action: { [weak delegate] in
                    guard let delegate else { return }
                    SignalApp.shared.dismissAllModals(animated: true, completion: {
                        SignalApp.shared.presentConversationForThread(
                            threadUniqueId: delegate.thread.uniqueId,
                            action: .compose,
                            animated: true,
                        )
                    })
                },
            ))
        }

        if ConversationViewController.canCall(threadViewModel: delegate.threadViewModel) {
            let callService = AppEnvironment.shared.callService!
            let currentCall = callService.callServiceState.currentCall
            let hasCurrentCall = currentCall != nil
            let isCurrentCallForThread = { () -> Bool in
                switch currentCall?.mode {
                case nil: return false
                case .individual(let call): return call.thread.uniqueId == delegate.thread.uniqueId
                case .groupThread(let call): return call.groupId.serialize() == (delegate.thread as? TSGroupThread)?.groupId
                case .callLink: return false
                }
            }()

            if options.contains(.videoCall) {
                buttons.append(buildIconButton(
                    icon: .buttonVideoCall,
                    title: OWSLocalizedString(
                        "CONVERSATION_SETTINGS_VIDEO_CALL_BUTTON",
                        comment: "Button to start a video call",
                    ),
                    isEnabled: isCurrentCallForThread || !hasCurrentCall,
                    action: { [weak delegate] in
                        delegate?.startCall(withVideo: true)
                    },
                ))
            }

            if !delegate.thread.isGroupThread, options.contains(.audioCall) {
                buttons.append(buildIconButton(
                    icon: .buttonVoiceCall,
                    title: OWSLocalizedString(
                        "CONVERSATION_SETTINGS_VOICE_CALL_BUTTON",
                        comment: "Button to start a voice call",
                    ),
                    isEnabled: isCurrentCallForThread || !hasCurrentCall,
                    action: { [weak delegate] in
                        delegate?.startCall(withVideo: false)
                    },
                ))
            }
        }

        if options.contains(.mute) {
            buttons.append(buildIconButton(
                icon: .buttonMute,
                title: delegate.threadViewModel.isMuted
                    ? OWSLocalizedString(
                        "CONVERSATION_SETTINGS_MUTED_BUTTON",
                        comment: "Button to unmute the chat",
                    )
                    : OWSLocalizedString(
                        "CONVERSATION_SETTINGS_MUTE_BUTTON",
                        comment: "Button to mute the chat",
                    ),
                menu: ConversationSettingsViewController.muteUnmuteMenu(
                    for: delegate.threadViewModel,
                    actionExecuted: { [weak delegate] in
                        delegate?.updateTableContents(shouldReload: true)
                    },
                ),
            ))
        }

        if options.contains(.search), !delegate.isGroupV1Thread {
            buttons.append(buildIconButton(
                icon: .buttonSearch,
                title: OWSLocalizedString(
                    "CONVERSATION_SETTINGS_SEARCH_BUTTON",
                    comment: "Button to search the chat",
                ),
                action: { [weak delegate] in
                    delegate?.tappedConversationSearch()
                },
            ))
        }

        let spacerWidth: CGFloat = 8
        let totalSpacerWidth = CGFloat(buttons.count - 1) * spacerWidth
        let maxAvailableButtonWidth = delegate.tableViewController.view.width
            - (delegate.tableViewController.cellOuterInsets.totalWidth + totalSpacerWidth)
        let minButtonWidth = maxAvailableButtonWidth / 4

        var buttonWidth = max(maxIconButtonWidth, minButtonWidth)
        let needsTwoRows = buttonWidth * CGFloat(buttons.count) > maxAvailableButtonWidth
        if needsTwoRows { buttonWidth *= 2 }
        buttons.forEach { $0.autoSetDimension(.width, toSize: buttonWidth) }

        func addButtonRow(_ buttons: [UIView]) {
            let stackView = UIStackView()
            stackView.axis = .horizontal
            stackView.distribution = .fillEqually
            stackView.alignment = .top
            stackView.spacing = spacerWidth
            buttons.forEach { stackView.addArrangedSubview($0) }
            subviews.append(stackView)
        }

        subviews.append(.spacer(withHeight: 20))

        if needsTwoRows {
            addButtonRow(Array(buttons.prefix(Int(ceil(CGFloat(buttons.count) / 2)))))
            subviews.append(.spacer(withHeight: 8))
            addButtonRow(buttons.suffix(Int(floor(CGFloat(buttons.count) / 2))))
        } else {
            addButtonRow(buttons)
        }
    }

    private var maxIconButtonWidth: CGFloat = 0
    mutating func buildIconButton(icon: ThemeIcon, title: String, isEnabled: Bool = true, action: @escaping () -> Void) -> UIView {
        let button = SettingsHeaderButton(title: title.capitalized, icon: icon) { [weak delegate] in
            delegate?.tappedButton()
            action()
        }
        button.isEnabled = isEnabled
        button.buttonBackgroundColor = delegate.tableViewController.cellBackgroundColor
        button.selectedButtonBackgroundColor = delegate.tableViewController.cellSelectedBackgroundColor

        if maxIconButtonWidth < button.minimumWidth {
            maxIconButtonWidth = button.minimumWidth
        }

        return button
    }

    mutating func buildIconButton(icon: ThemeIcon, title: String, isEnabled: Bool = true, menu: UIMenu) -> UIView {
        let button = SettingsHeaderButton(title: title.capitalized, icon: icon)
        button.isEnabled = isEnabled
        button.menu = menu
        button.buttonBackgroundColor = delegate.tableViewController.cellBackgroundColor
        button.selectedButtonBackgroundColor = delegate.tableViewController.cellSelectedBackgroundColor

        if maxIconButtonWidth < button.minimumWidth {
            maxIconButtonWidth = button.minimumWidth
        }

        return button
    }

    mutating func addGroupDescriptionPreview(text: String) {
        let previewView: GroupDescriptionPreviewView
        if
            let groupThread = delegate.thread as? TSGroupThread,
            delegate.canEditConversationAttributes
        {
            previewView = GroupDescriptionPreviewView(editableGroupThread: groupThread)
            previewView.delegate = delegate.groupDescriptionDelegate
        } else {
            previewView = GroupDescriptionPreviewView()
        }
        previewView.descriptionText = text
        previewView.groupName = delegate.threadName(
            renderLocalUserAsNoteToSelf: true,
            transaction: transaction,
        )
        previewView.font = .dynamicTypeSubheadlineClamped
        previewView.textColor = Theme.secondaryTextAndIconColor
        previewView.textAlignment = .center
        previewView.numberOfLines = 2

        subviews.append(previewView)
        hasSubtitleLabel = true
    }

    mutating func addCreateGroupDescriptionButton() {
        let button = OWSButton { [weak delegate] in delegate?.didTapAddGroupDescription() }
        button.setTitle(OWSLocalizedString(
            "GROUP_DESCRIPTION_PLACEHOLDER",
            comment: "Placeholder text for 'group description' field.",
        ), for: .normal)
        button.setTitleColor(Theme.secondaryTextAndIconColor, for: .normal)
        button.titleLabel?.font = .dynamicTypeSubheadlineClamped
        // For some reason, setting edge insets to 0 uses a default, non-zero inset
        button.ows_contentEdgeInsets = .init(hMargin: 0, vMargin: .ulpOfOne)

        subviews.append(button)
        hasSubtitleLabel = true
    }

    func buildGroupTerminatedBanner() -> UIView {
        let banner = UIView()
        let textStackView = UIStackView()
        textStackView.axis = .horizontal
        textStackView.spacing = 6
        banner.backgroundColor = UIColor.Signal.quaternaryFill
        banner.layer.cornerRadius = 16
        banner.layer.masksToBounds = true

        let iconLabel = UILabel()
        let textLabel = UILabel()
        textLabel.font = .dynamicTypeSubheadlineClamped
        textLabel.textColor = UIColor.Signal.label
        textLabel.numberOfLines = 0
        iconLabel.attributedText = SignalSymbol.groupXInline.attributedString(for: .subheadline, clamped: true)
        textLabel.text = OWSLocalizedString(
            "END_GROUP_BANNER_LABEL",
            comment: "Label for a banner in group settings indicating that the group has been ended",
        )

        textStackView.addArrangedSubview(iconLabel)
        textStackView.addArrangedSubview(textLabel)

        banner.addSubview(textStackView)

        textStackView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            textStackView.leadingAnchor.constraint(equalTo: banner.leadingAnchor, constant: 12),
            textStackView.trailingAnchor.constraint(equalTo: banner.trailingAnchor, constant: -12),
            textStackView.topAnchor.constraint(equalTo: banner.topAnchor, constant: 6),
            textStackView.bottomAnchor.constraint(equalTo: banner.bottomAnchor, constant: -6),
        ])

        banner.isAccessibilityElement = true
        banner.accessibilityLabel = textLabel.text

        return banner
    }

    func buildAvatarView(transaction: DBReadTransaction) -> UIView {
        let avatarView = ConversationAvatarView(
            sizeClass: sizeClass,
            localUserDisplayMode: options.contains(.renderLocalUserAsNoteToSelf) ? .noteToSelf : .asUser,
        )

        avatarView.update(transaction) {
            $0.dataSource = .thread(delegate.thread)
            $0.storyConfiguration = .autoUpdate()
        }
        avatarView.interactionDelegate = delegate

        // Track the most recent avatar view.
        delegate.avatarView = avatarView
        return avatarView
    }

    func buildThreadNameLabel() -> UIButton {
        var config = UIButton.Configuration.plain()
        let title = delegate.threadAttributedString(
            renderLocalUserAsNoteToSelf: options.contains(.renderLocalUserAsNoteToSelf),
            tx: transaction,
        ).styled(with: .alignment(.center))
        config.attributedTitle = AttributedString(title)
        config.titleLineBreakMode = .byWordWrapping
        config.baseForegroundColor = UIColor.Signal.label
        let action: UIAction? = if delegate.canTapThreadName {
            UIAction { [weak delegate] _ in
                delegate?.didTapThreadName()
            }
        } else {
            nil
        }
        return UIButton(configuration: config, primaryAction: action)
    }

    static func threadAttributedString(
        threadName: String,
        isNoteToSelf: Bool,
        isSystemContact: Bool,
        canTap: Bool,
        tx: DBReadTransaction,
    ) -> NSAttributedString {
        let font = UIFont.dynamicTypeFont(ofStandardSize: 26, weight: .semibold)

        let attributedString = NSMutableAttributedString(string: threadName, attributes: [
            .foregroundColor: UIColor.label,
            .font: font,
        ])

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

        if isSystemContact {
            let contactIcon = SignalSymbol.personCircle.attributedString(
                dynamicTypeBaseSize: 20,
                weight: .bold,
                leadingCharacter: .nonBreakingSpace,
            )
            attributedString.append(contactIcon)
        }

        if canTap {
            let chevron = SignalSymbol.chevronTrailing(for: threadName).attributedString(
                dynamicTypeBaseSize: 24,
                weight: .bold,
                leadingCharacter: .nonBreakingSpace,
                attributes: [.foregroundColor: UIColor.Signal.secondaryLabel],
            )
            attributedString.append(chevron)
        }

        return attributedString
    }

    @discardableResult
    mutating func addSubtitleLabel(text: String) -> OWSLabel {
        addSubtitleLabel(attributedText: NSAttributedString(string: text))
    }

    private var hasSubtitleLabel = false

    @discardableResult
    mutating func addSubtitleLabel(attributedText: NSAttributedString) -> OWSLabel {
        subviews.append(UIView.spacer(withHeight: 4))
        let label = buildHeaderSubtitleLabel(attributedText: attributedText)
        subviews.append(label)
        hasSubtitleLabel = true
        return label
    }

    mutating func addMemberLabel(label: String, color: UIColor) -> UILabel {
        subviews.append(UIView.spacer(withHeight: 4))
        let memberLabelLabel = CVCapsuleLabel(
            attributedText: NSAttributedString(string: label),
            textColor: color,
            font: nil,
            highlightRange: NSRange(location: 0, length: (label as NSString).length),
            highlightFont: .dynamicTypeSubheadlineClamped,
            axLabelPrefix: OWSLocalizedString(
                "MEMBER_LABEL_AX_PREFIX",
                comment: "Accessibility prefix for member labels.",
            ),
            presentationContext: .nonMessageBubble,
            numberOfLines: 1,
            signalSymbolRange: nil,
            onTap: { [weak delegate] in
                delegate?.didTapMemberLabel()
            },
        )

        subviews.append(memberLabelLabel)
        hasSubtitleLabel = true
        return memberLabelLabel
    }

    mutating func addLegacyGroupView() {
        subviews.append(UIView.spacer(withHeight: 12))

        let legacyGroupView = LegacyGroupView(viewController: delegate)
        legacyGroupView.configure()
        legacyGroupView.backgroundColor = delegate.tableViewController.cellBackgroundColor
        subviews.append(legacyGroupView)
    }

    func buildHeaderSubtitleLabel(attributedText: NSAttributedString) -> OWSLabel {
        let label = OWSLabel()

        // Defaults need to be set *before* assigning the attributed text,
        // or the attributes will get overridden
        label.textColor = Theme.secondaryTextAndIconColor
        label.lineBreakMode = .byTruncatingTail
        label.font = .dynamicTypeSubheadlineClamped

        label.attributedText = attributedText

        return label
    }

    func build() -> UIView {
        let header = UIStackView(arrangedSubviews: subviews)
        header.axis = .vertical
        header.alignment = .center
        header.layoutMargins = .init(top: 0, left: 0, bottom: 24, right: 0)
        header.isLayoutMarginsRelativeArrangement = true

        header.isUserInteractionEnabled = true
        header.accessibilityIdentifier = UIView.accessibilityIdentifier(in: delegate, name: "mainSectionHeader")
        if !options.contains(.noBackground) {
            header.addBackgroundView(withBackgroundColor: delegate.tableViewController.tableBackgroundColor)
        }

        return header
    }
}

// MARK: -

@MainActor
protocol ConversationHeaderDelegate: UIViewController, ConversationAvatarViewDelegate {
    var tableViewController: OWSTableViewController2 { get }

    var thread: TSThread { get }
    var threadViewModel: ThreadViewModel { get }

    func threadName(renderLocalUserAsNoteToSelf: Bool, transaction: DBReadTransaction) -> String

    var avatarView: ConversationAvatarView? { get set }

    var isGroupV1Thread: Bool { get }
    var canEditConversationAttributes: Bool { get }
    var groupDescriptionDelegate: GroupDescriptionViewControllerDelegate? { get }

    func updateTableContents(shouldReload: Bool)
    func tappedConversationSearch()

    func startCall(withVideo: Bool)

    func tappedButton()

    func didTapUnblockThread(completion: @escaping () -> Void)

    func didTapAddGroupDescription()

    var canTapThreadName: Bool { get }
    func didTapThreadName()

    func didTapMemberLabel()
}

// MARK: -

extension ConversationHeaderDelegate {
    func threadName(renderLocalUserAsNoteToSelf: Bool, transaction: DBReadTransaction) -> String {
        var threadName: String
        if thread.isNoteToSelf, !renderLocalUserAsNoteToSelf {
            let profileManager = SSKEnvironment.shared.profileManagerRef
            threadName = profileManager.localUserProfile(tx: transaction)?.filteredFullName ?? ""
        } else {
            threadName = SSKEnvironment.shared.contactManagerRef.displayName(for: thread, transaction: transaction)
        }

        if let contactThread = thread as? TSContactThread {
            if let phoneNumber = contactThread.contactAddress.phoneNumber, phoneNumber == threadName {
                threadName = PhoneNumber.bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber(phoneNumber)
            }
        }

        return threadName
    }

    func threadAttributedString(renderLocalUserAsNoteToSelf: Bool, tx: DBReadTransaction) -> NSAttributedString {
        let threadName = threadName(renderLocalUserAsNoteToSelf: renderLocalUserAsNoteToSelf, transaction: tx)

        let isSystemContact =
            if let contactThread = self.thread as? TSContactThread {
                SSKEnvironment.shared.contactManagerRef.fetchSignalAccount(
                    for: contactThread.contactAddress,
                    transaction: tx,
                ) != nil
            } else {
                false
            }

        return ConversationHeaderBuilder.threadAttributedString(
            threadName: threadName,
            isNoteToSelf: thread.isNoteToSelf,
            isSystemContact: isSystemContact,
            canTap: self.canTapThreadName,
            tx: tx,
        )
    }

    func startCall(withVideo: Bool) {
        guard ConversationViewController.canCall(threadViewModel: threadViewModel) else {
            owsFailDebug("Tried to start a call when calls are disabled")
            return
        }
        let callTarget: CallTarget
        if let contactThread = thread as? TSContactThread {
            callTarget = .individual(contactThread)
        } else if let groupThread = thread as? TSGroupThread {
            if withVideo {
                if let groupId = try? groupThread.groupIdentifier {
                    callTarget = .groupThread(groupId)
                } else {
                    owsFailDebug("Tried to start a group call with an invalid groupId")
                    return
                }
            } else {
                owsFailDebug("Tried to start an audio only group call")
                return
            }
        } else {
            owsFailDebug("Tried to start an invalid call")
            return
        }

        guard !threadViewModel.isBlocked else {
            didTapUnblockThread { [weak self] in
                self?.startCall(withVideo: withVideo)
            }
            return
        }

        let callService = AppEnvironment.shared.callService!
        if let currentCall = callService.callServiceState.currentCall {
            if currentCall.mode.matches(callTarget) {
                AppEnvironment.shared.windowManagerRef.returnToCallView()
            } else {
                owsFailDebug("Tried to start call while call was ongoing")
            }
            return
        }

        // We initiated a call, so if there was a pending message request we should accept it.
        ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequestAndSetDefaultTimerWithSneakyTransaction(thread)
        callService.initiateCall(to: callTarget, isVideo: withVideo)
    }
}

extension ConversationSettingsViewController: ConversationHeaderDelegate {
    var tableViewController: OWSTableViewController2 { self }

    func buildMainHeader() -> UIView {
        let options: ConversationHeaderBuilder.Options
        if isTerminatedGroup {
            options = [.mute, .search]
        } else if callRecords.isEmpty {
            options = [.videoCall, .audioCall, .mute, .search, .renderLocalUserAsNoteToSelf]
        } else {
            // Call details
            options = [.message, .videoCall, .audioCall, .mute]
        }

        return ConversationHeaderBuilder.buildHeader(
            for: thread,
            sizeClass: .eightyEight,
            options: options,
            memberLabel: nil,
            delegate: self,
        )
    }

    var groupDescriptionDelegate: GroupDescriptionViewControllerDelegate? { self }

    func tappedButton() {}

    func didTapAddGroupDescription() {
        guard let groupThread = thread as? TSGroupThread else { return }
        let vc = GroupDescriptionViewController(
            groupModel: groupThread.groupModel,
            options: [.editImmediately, .updateImmediately],
        )
        vc.descriptionDelegate = self
        presentFormSheet(OWSNavigationController(rootViewController: vc), animated: true)
    }

    var canTapThreadName: Bool {
        !thread.isGroupThread && !thread.isNoteToSelf
    }

    func didTapThreadName() {
        guard let contactThread = self.thread as? TSContactThread else {
            owsFailDebug("Conversation name should only be tappable for contact threads")
            return
        }
        ContactAboutSheet(thread: contactThread, spoilerState: self.spoilerState)
            .present(from: self)
    }

    func didTapMemberLabel() {}
}

extension ConversationSettingsViewController: GroupDescriptionViewControllerDelegate {
    func groupDescriptionViewControllerDidComplete(groupDescription: String?) {
        reloadThreadAndUpdateContent()
    }
}

// MARK: -

public class OWSLabel: UILabel {

    // MARK: - Tap

    public typealias TapBlock = () -> Void
    private var tapBlock: TapBlock?

    public func addTapGesture(_ tapBlock: @escaping TapBlock) {
        AssertIsOnMainThread()
        owsAssertDebug(self.tapBlock == nil)

        self.tapBlock = tapBlock
        isUserInteractionEnabled = true
        addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTap)))
    }

    @objc
    private func didTap() {
        guard let tapBlock else {
            owsFailDebug("Missing tapBlock.")
            return
        }
        tapBlock()
    }

    // MARK: - Long Press

    public typealias LongPressBlock = () -> Void
    private var longPressBlock: LongPressBlock?

    public func addLongPressGesture(_ longPressBlock: @escaping LongPressBlock) {
        AssertIsOnMainThread()
        owsAssertDebug(self.longPressBlock == nil)

        self.longPressBlock = longPressBlock
        isUserInteractionEnabled = true
        addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(didLongPress)))
    }

    @objc
    private func didLongPress(sender: UIGestureRecognizer) {
        guard sender.state == .began else {
            return
        }
        guard let longPressBlock else {
            owsFailDebug("Missing longPressBlock.")
            return
        }
        longPressBlock()
    }
}