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

import Foundation
import SignalServiceKit
import SignalUI
import UIKit

extension ConversationSettingsViewController {

    // MARK: - Helpers

    private var iconSpacingSmall: CGFloat {
        return ContactCellView.avatarTextHSpacing
    }

    private var iconSpacingLarge: CGFloat {
        return OWSTableItem.iconSpacing
    }

    private var isContactThread: Bool {
        return !thread.isGroupThread
    }

    // MARK: - Table

    func updateTableContents(shouldReload: Bool = true) {
        let contents = OWSTableContents()

        let isNoteToSelf = thread.isNoteToSelf

        let callDetailsSection = createCallSection()
        if let callDetailsSection {
            contents.add(callDetailsSection)
        }

        let mainSection = OWSTableSection()

        let firstSection = callDetailsSection ?? mainSection

        let header = buildMainHeader()
        firstSection.customHeaderView = header

        // Main section.
        addDisappearingMessagesItem(to: mainSection)
        addNicknameItemIfNecessary(to: mainSection)
        addColorAndWallpaperSettingsItem(to: mainSection)
        if !isNoteToSelf { addSoundAndNotificationSettingsItem(to: mainSection) }
        addSafetyNumberItemIfNecessary(to: mainSection)

        contents.add(mainSection)

        // Middle sections
        addSystemContactSectionIfNecessary(to: contents)
        addAllMediaSectionIfNecessary(to: contents)
        addBadgesItemIfNecessary(to: contents)

        // Group sections
        if let groupModel = currentGroupModel, !groupModel.isPlaceholder {
            contents.add(buildGroupMembershipSection(groupModel: groupModel, sectionIndex: contents.sections.count))

            if let groupModelV2 = groupModel as? TSGroupModelV2 {
                if groupModelV2.isTerminated {
                    buildTerminatedGroupSettingsSection(contents: contents)
                } else {
                    buildGroupSettingsSection(groupModelV2: groupModelV2, contents: contents)
                }
            }
        } else if isContactThread, hasGroupThreads, !isNoteToSelf {
            contents.add(buildMutualGroupsSection(sectionIndex: contents.sections.count))
        }

        // Bottom sections
        if
            !isNoteToSelf,
            !thread.isGroupV1Thread
        {
            contents.add(buildBlockAndLeaveSection())
        }

        if
            BuildFlags.GroupTerminate.send,
            let groupModelV2 = currentGroupModel as? TSGroupModelV2,
            groupModelV2.groupMembership.isLocalUserFullMemberAndAdministrator,
            !groupModelV2.isTerminated
        {
            contents.add(buildEndGroupSection())
        }

        if DebugFlags.internalSettings {
            contents.add(buildInternalSection())
        }

        let emptySection = OWSTableSection()
        emptySection.customFooterHeight = 24
        contents.add(emptySection)

        let setContents = {
            self.setContents(contents, shouldReload: shouldReload)
        }

        if shouldReload {
            // This DispatchQueue.main.async remedies an issue (worsened on iOS 26)
            // where the contents of custom cells would grow from the corner during
            // transition animations.
            DispatchQueue.main.async(setContents)
        } else {
            setContents()
        }
    }

    // MARK: Calls section

    private func createCallSection() -> OWSTableSection? {
        return Self.createCallHistorySection(callRecords: callRecords)
    }

    static func createCallHistorySection(callRecords: [CallRecord]) -> OWSTableSection? {
        guard let callRecord = callRecords.first else {
            return nil
        }

        let section = OWSTableSection()

        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.spacing = 8

        let dateLabel = UILabel()
        dateLabel.font = .dynamicTypeBody
        dateLabel.textColor = Theme.primaryTextColor
        // We always want to show the absolute date with year
        dateLabel.text = DateUtil.formatOldDate(callRecord.callBeganDate)
        stackView.addArrangedSubview(dateLabel)
        stackView.setCustomSpacing(10, after: dateLabel)

        typealias CallRow = (icon: ThemeIcon, description: String, timestamp: String)
        let callRows: [CallRow] = callRecords.map { callRecord in
            let icon: ThemeIcon = {
                switch callRecord.callType {
                case .audioCall:
                    return .phone16
                case .adHocCall, .groupCall, .videoCall:
                    return .video16
                }
            }()

            let description: String = {
                enum CallMedium {
                    case audioCall
                    case videoCall
                }
                let callMedium: CallMedium
                switch callRecord.callType {
                case .adHocCall:
                    return CallStrings.callLink
                case .audioCall:
                    callMedium = .audioCall
                case .groupCall, .videoCall:
                    callMedium = .videoCall
                }
                if callRecord.callStatus.isMissedCall {
                    switch callMedium {
                    case .audioCall:
                        return OWSLocalizedString(
                            "CONVERSATION_SETTINGS_CALL_DETAILS_MISSED_VOICE_CALL",
                            comment: "A label indicating that a call was an missed voice call",
                        )
                    case .videoCall:
                        return OWSLocalizedString(
                            "CONVERSATION_SETTINGS_CALL_DETAILS_MISSED_VIDEO_CALL",
                            comment: "A label indicating that a call was an missed video call",
                        )
                    }
                }
                switch callRecord.callDirection {
                case .outgoing:
                    switch callMedium {
                    case .audioCall:
                        return OWSLocalizedString(
                            "CONVERSATION_SETTINGS_CALL_DETAILS_OUTGOING_VOICE_CALL",
                            comment: "A label indicating that a call was an outgoing voice call",
                        )
                    case .videoCall:
                        return OWSLocalizedString(
                            "CONVERSATION_SETTINGS_CALL_DETAILS_OUTGOING_VIDEO_CALL",
                            comment: "A label indicating that a call was an outgoing video call",
                        )
                    }
                case .incoming:
                    switch callMedium {
                    case .audioCall:
                        return OWSLocalizedString(
                            "CONVERSATION_SETTINGS_CALL_DETAILS_INCOMING_VOICE_CALL",
                            comment: "A label indicating that a call was an incoming voice call",
                        )
                    case .videoCall:
                        return OWSLocalizedString(
                            "CONVERSATION_SETTINGS_CALL_DETAILS_INCOMING_VIDEO_CALL",
                            comment: "A label indicating that a call was an incoming video call",
                        )
                    }
                }
            }()

            let timestamp = DateUtil.formatDateAsTime(callRecord.callBeganDate)
            return (icon, description, timestamp)
        }

        for callRow in callRows {
            stackView.addArrangedSubview({
                let hStack = UIStackView()
                hStack.axis = .horizontal
                hStack.spacing = 6
                hStack.addArrangedSubview(UIImageView.withTemplateIcon(
                    callRow.icon,
                    tintColor: Theme.primaryTextColor,
                    constrainedTo: .square(16),
                ))
                hStack.tintColor = Theme.primaryTextColor

                let descriptionLabel = UILabel()
                descriptionLabel.font = .dynamicTypeSubheadline
                descriptionLabel.textColor = Theme.primaryTextColor
                descriptionLabel.text = callRow.description
                hStack.addArrangedSubview(descriptionLabel)

                hStack.addArrangedSubview(UIView.hStretchingSpacer())

                let timestampLabel = UILabel()
                timestampLabel.font = .dynamicTypeSubheadline
                timestampLabel.textColor = Theme.secondaryTextAndIconColor
                timestampLabel.text = callRow.timestamp
                hStack.addArrangedSubview(timestampLabel)

                return hStack
            }())
        }

        section.add(.init(customCellBlock: {
            let cell = OWSTableItem.newCell()
            cell.contentView.addSubview(stackView)
            stackView.autoPinEdgesToSuperviewMargins()
            return cell
        }))

        return section
    }

    // MARK: Middle sections

    private func addAllMediaSectionIfNecessary(to contents: OWSTableContents) {
        guard !recentMedia.isEmpty else { return }

        let section = OWSTableSection()
        section.headerTitle = OWSLocalizedString(
            "CONVERSATION_SETTINGS_ALL_MEDIA_HEADER",
            comment: "Header title for the section showing all media in conversation settings",
        )

        section.add(.init(
            customCellBlock: { [weak self] in
                let cell = OWSTableItem.newCell()
                guard let self else { return cell }

                let stackView = UIStackView()
                stackView.axis = .horizontal
                stackView.spacing = 5
                cell.contentView.addSubview(stackView)
                stackView.autoPinEdges(toSuperviewMarginsExcludingEdge: .bottom)

                let totalSpacerSize = CGFloat(self.maximumRecentMedia - 1) * stackView.spacing
                let availableWidth = self.view.width - ((Self.cellHInnerMargin * 2) + self.cellOuterInsets.totalWidth + self.view.safeAreaInsets.totalWidth)
                let imageWidth = (availableWidth - totalSpacerSize) / CGFloat(self.maximumRecentMedia)

                for (attachmentStream, imageView) in self.recentMedia.orderedValues {
                    let button = OWSButton { [weak self] in
                        self?.showMediaPageView(for: attachmentStream)
                    }
                    stackView.addArrangedSubview(button)
                    button.autoSetDimensions(to: CGSize(square: imageWidth))

                    imageView.backgroundColor = .ows_middleGray

                    button.addSubview(imageView)
                    imageView.autoPinEdgesToSuperviewEdges()

                    let overlayView = UIView()
                    overlayView.isUserInteractionEnabled = false
                    overlayView.backgroundColor = .ows_blackAlpha05
                    overlayView.layer.cornerRadius = imageView.layer.cornerRadius
                    overlayView.clipsToBounds = true
                    button.addSubview(overlayView)
                    overlayView.autoPinEdgesToSuperviewEdges()
                }

                if self.recentMedia.count < self.maximumRecentMedia {
                    stackView.addArrangedSubview(.hStretchingSpacer())
                    stackView.autoPinEdge(toSuperviewMargin: .bottom)
                } else {
                    let seeAllLabel = UILabel()
                    seeAllLabel.textColor = Theme.primaryTextColor
                    seeAllLabel.font = OWSTableItem.primaryLabelFont
                    seeAllLabel.text = CommonStrings.seeAllButton

                    seeAllLabel.autoSetDimension(.height, toSize: OWSTableItem.primaryLabelFont.lineHeight)
                    cell.contentView.addSubview(seeAllLabel)
                    seeAllLabel.autoPinEdges(toSuperviewMarginsExcludingEdge: .top)
                    seeAllLabel.autoPinEdge(.top, to: .bottom, of: stackView, withOffset: 14)
                }

                return cell
            },
            actionBlock: { [weak self] in
                self?.showMediaGallery()
            },
        ))

        contents.add(section)
    }

    private func addBadgesItemIfNecessary(to contents: OWSTableContents) {
        guard !thread.isNoteToSelf, isContactThread else { return }
        guard let contactAddress = (thread as? TSContactThread)?.contactAddress else { return }

        let (visibleBadges, shortName) = SSKEnvironment.shared.databaseStorageRef.read { tx -> ([OWSUserProfileBadgeInfo], String) in
            let visibleBadges: [OWSUserProfileBadgeInfo] = {
                let tsAccountManager = DependenciesBridge.shared.tsAccountManager
                guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx) else {
                    return []
                }
                let address = OWSUserProfile.internalAddress(for: contactAddress, localIdentifiers: localIdentifiers)
                guard let userProfile = OWSUserProfile.getUserProfile(for: address, tx: tx) else {
                    return []
                }
                return userProfile.visibleBadges
            }()
            let shortName = SSKEnvironment.shared.contactManagerRef.displayName(for: contactAddress, tx: tx).resolvedValue(useShortNameIfAvailable: true)
            return (visibleBadges, shortName)
        }
        guard !visibleBadges.isEmpty else { return }

        availableBadges = visibleBadges
        contents.add(
            .init(
                title: OWSLocalizedString("CONVERSATION_SETTINGS_BADGES_HEADER", comment: "Header title for a contact's badges in conversation settings"),
                items: [
                    OWSTableItem(customCellBlock: { [weak self] in
                        let cell = OWSTableItem.newCell()
                        guard let self else { return cell }
                        let collectionView = BadgeCollectionView(dataSource: self)
                        collectionView.badgeSelectionMode = .detailsSheet(owner: .remote(shortName: shortName))

                        cell.contentView.addSubview(collectionView)
                        collectionView.autoPinEdgesToSuperviewMargins()

                        // Pre-layout the collection view so the UITableView caches the correct resolved
                        // autolayout height.
                        collectionView.layoutIfNeeded()

                        return cell
                    }, actionBlock: nil),
                ],
                footerTitle: OWSLocalizedString("CONVERSATION_SETTINGS_BADGES_FOOTER", comment: "Footer string for a contact's badges in conversation settings"),
            ),
        )
    }

    // MARK: Main section

    private func addSafetyNumberItemIfNecessary(to section: OWSTableSection) {
        guard !thread.isNoteToSelf, !isGroupThread, thread.hasSafetyNumbers() else { return }

        section.add(
            OWSTableItem.disclosureItem(
                icon: .contactInfoSafetyNumber,
                withText: OWSLocalizedString(
                    "VERIFY_PRIVACY",
                    comment: "Label for button or row which allows users to verify the safety number of another user.",
                ),
                actionBlock: { [weak self] in
                    self?.showVerificationView()
                },
            ),
        )
    }

    private func addSystemContactSectionIfNecessary(to contents: OWSTableContents) {
        guard !thread.isNoteToSelf, let contactThread = thread as? TSContactThread else { return }

        let section = OWSTableSection()

        if isSystemContact {
            section.add(
                OWSTableItem.disclosureItem(
                    icon: .contactInfoUserInContacts,
                    withText: OWSLocalizedString(
                        "CONVERSATION_SETTINGS_VIEW_IS_SYSTEM_CONTACT",
                        comment: "Indicates that user is in the system contacts list.",
                    ),
                    actionBlock: { [weak self] in
                        self?.presentCreateOrEditContactViewController(
                            address: contactThread.contactAddress,
                            editImmediately: false,
                        )
                    },
                ),
            )
        } else if contactThread.contactAddress.phoneNumber != nil {
            section.add(
                OWSTableItem.disclosureItem(
                    icon: .contactInfoAddToContacts,
                    withText: OWSLocalizedString(
                        "CONVERSATION_SETTINGS_ADD_TO_SYSTEM_CONTACTS",
                        comment: "button in conversation settings view.",
                    ),
                    actionBlock: { [weak self] in
                        self?.showAddToSystemContactsActionSheet(contactThread: contactThread)
                    },
                ),
            )
        }

        contents.add(section)
    }

    private func addColorAndWallpaperSettingsItem(to section: OWSTableSection) {
        section.add(OWSTableItem(
            customCellBlock: { [weak self] in
                guard let self else {
                    owsFailDebug("Missing self")
                    return OWSTableItem.newCell()
                }

                let cell = OWSTableItem.buildCell(
                    icon: .chatSettingsWallpaper,
                    itemName: OWSLocalizedString(
                        "SETTINGS_ITEM_COLOR_AND_WALLPAPER",
                        comment: "Label for settings view that allows user to change the chat color and wallpaper.",
                    ),
                    accessoryType: .disclosureIndicator,
                    accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "color_and_wallpaper"),
                )
                return cell
            },
            actionBlock: { [weak self] in
                self?.showColorAndWallpaperSettingsView()
            },
        ))
    }

    private func addSoundAndNotificationSettingsItem(to section: OWSTableSection) {
        section.add(OWSTableItem(
            customCellBlock: { [weak self] in
                guard let self else {
                    owsFailDebug("Missing self")
                    return OWSTableItem.newCell()
                }

                let cell = OWSTableItem.buildCell(
                    icon: .chatSettingsMessageSound,
                    itemName: OWSLocalizedString(
                        "SOUND_AND_NOTIFICATION_SETTINGS",
                        comment: "table cell label in conversation settings",
                    ),
                    accessoryType: .disclosureIndicator,
                    accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "sound_and_notifications"),
                )
                return cell
            },
            actionBlock: { [weak self] in
                self?.showSoundAndNotificationsSettingsView()
            },
        ))
    }

    private func addDisappearingMessagesItem(to section: OWSTableSection) {

        let canEditConversationAttributes = self.canEditConversationAttributes
        let disappearingMessagesConfiguration = self.disappearingMessagesConfiguration
        let thread = self.thread

        section.add(.init(
            customCellBlock: { [weak self] in
                guard let self else { return UITableViewCell() }
                let cell = OWSTableItem.buildCell(
                    icon: disappearingMessagesConfiguration.isEnabled
                        ? .chatSettingsTimerOn
                        : .chatSettingsTimerOff,
                    itemName: OWSLocalizedString(
                        "DISAPPEARING_MESSAGES",
                        comment: "table cell label in conversation settings",
                    ),
                    accessoryText: disappearingMessagesConfiguration.isEnabled
                        ? DateUtil.formatDuration(seconds: disappearingMessagesConfiguration.durationSeconds, useShortFormat: true)
                        : CommonStrings.switchOff,
                    accessoryType: .disclosureIndicator,
                    customColor: canEditConversationAttributes && !isTerminatedGroup ? nil : Theme.secondaryTextAndIconColor,
                    accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "disappearing_messages"),
                )
                cell.isUserInteractionEnabled = canEditConversationAttributes && !isTerminatedGroup
                return cell
            },
            actionBlock: { [weak self] in
                let vc = DisappearingMessagesTimerSettingsViewController(
                    initialConfiguration: disappearingMessagesConfiguration,
                    settingsMode: .chat(thread: thread),
                ) { configuration in
                    self?.disappearingMessagesConfiguration = configuration
                    self?.updateTableContents()
                    NotificationCenter.default.post(name: ChatListViewController.clearSearch, object: nil)
                }
                self?.presentFormSheet(OWSNavigationController(rootViewController: vc), animated: true)
            },
        ))
    }

    private func addNicknameItemIfNecessary(to section: OWSTableSection) {
        guard
            !self.thread.isNoteToSelf,
            let thread = self.thread as? TSContactThread
        else { return }
        section.add(.item(
            icon: .buttonEdit,
            name: OWSLocalizedString(
                "NICKNAME_BUTTON_TITLE",
                comment: "Title for the table cell in conversation settings for presenting the profile nickname editor.",
            ),
            accessoryType: .disclosureIndicator,
            actionBlock: { [weak self] in
                guard let self else { return }
                let db = DependenciesBridge.shared.db

                let nicknameEditor = db.read { tx in
                    NicknameEditorViewController.create(
                        for: thread.contactAddress,
                        context: .init(
                            db: db,
                            nicknameManager: DependenciesBridge.shared.nicknameManager,
                        ),
                        tx: tx,
                    )
                }
                guard let nicknameEditor else { return }
                let navigationController = OWSNavigationController(rootViewController: nicknameEditor)
                self.presentFormSheet(navigationController, animated: true)
            },
        ))
    }

    // MARK: Bottom sections

    private func buildBlockAndLeaveSection() -> OWSTableSection {
        let section = OWSTableSection()

        if isGroupThread, isLocalUserFullOrInvitedMember, !isTerminatedGroup {
            section.add(OWSTableItem(
                customCellBlock: { [weak self] in
                    guard let self else {
                        owsFailDebug("Missing self")
                        return OWSTableItem.newCell()
                    }

                    return OWSTableItem.buildCell(
                        icon: .groupInfoLeaveGroup,
                        itemName: OWSLocalizedString(
                            "LEAVE_GROUP_ACTION",
                            comment: "table cell label in conversation settings",
                        ),
                        customColor: UIColor.ows_accentRed,
                        accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "leave_group"),
                    )
                },
                actionBlock: { [weak self] in
                    self?.didTapLeaveGroup()
                },
            ))
        }

        if !isTerminatedGroup {
            let isGroup = thread.isGroupThread
            let isBlocked = threadViewModel.isBlocked
            section.add(OWSTableItem(
                customCellBlock: {
                    let cellTitle: String
                    var customColor: UIColor?
                    if isBlocked {
                        cellTitle = isGroup ? OWSLocalizedString(
                            "CONVERSATION_SETTINGS_UNBLOCK_GROUP",
                            comment: "Label for 'unblock group' action in conversation settings view.",
                        ) : OWSLocalizedString(
                            "CONVERSATION_SETTINGS_UNBLOCK_USER",
                            comment: "Label for 'unblock user' action in conversation settings view.",
                        )
                    } else {
                        cellTitle = isGroup ? OWSLocalizedString(
                            "CONVERSATION_SETTINGS_BLOCK_GROUP",
                            comment: "Label for 'block group' action in conversation settings view.",
                        ) : OWSLocalizedString(
                            "CONVERSATION_SETTINGS_BLOCK_USER",
                            comment: "Label for 'block user' action in conversation settings view.",
                        )
                        customColor = UIColor.ows_accentRed
                    }
                    let cell = OWSTableItem.buildCell(
                        icon: .chatSettingsBlock,
                        itemName: cellTitle,
                        customColor: customColor,
                    )
                    return cell
                },
                actionBlock: { [weak self] in
                    if isBlocked {
                        self?.didTapUnblockThread()
                    } else {
                        self?.didTapBlockThread()
                    }
                },
            ))
        }

        let hasReportedSpam = SSKEnvironment.shared.databaseStorageRef.read { tx in
            return InteractionFinder(threadUniqueId: thread.uniqueId).hasUserReportedSpam(transaction: tx)
        }

        if !hasReportedSpam {
            section.add(OWSTableItem(
                customCellBlock: { [weak self] in
                    guard let self else {
                        owsFailDebug("Missing self")
                        return OWSTableItem.newCell()
                    }

                    return OWSTableItem.buildCell(
                        icon: .spam,
                        itemName: OWSLocalizedString(
                            "CONVERSATION_SETTINGS_REPORT_SPAM",
                            comment: "Label for 'report spam' action in conversation settings view.",
                        ),
                        customColor: UIColor.ows_accentRed,
                        accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "report_spam"),
                    )
                },
                actionBlock: { [weak self] in
                    self?.didTapReportSpam()
                },
            ))
        }

        return section
    }

    private func buildEndGroupSection() -> OWSTableSection {
        let section = OWSTableSection()

        section.add(OWSTableItem(
            customCellBlock: { [weak self] in
                guard let self else {
                    owsFailDebug("Missing self")
                    return OWSTableItem.newCell()
                }

                return OWSTableItem.buildCell(
                    icon: .xCircle,
                    itemName: OWSLocalizedString(
                        "END_GROUP_LABEL",
                        comment: "Label in conversation settings to end a group",
                    ),
                    customColor: UIColor.ows_accentRed,
                    accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "end_group"),
                )
            },
            actionBlock: { [weak self] in
                self?.didTapEndGroup()
            },
        ))

        return section
    }

    private func buildInternalSection() -> OWSTableSection {
        let section = OWSTableSection()

        section.add(OWSTableItem(
            customCellBlock: { [weak self] in
                guard let self else {
                    owsFailDebug("Missing self")
                    return OWSTableItem.newCell()
                }

                return OWSTableItem.buildCell(
                    icon: .settingsAdvanced,
                    itemName: "Internal",
                    accessoryType: .disclosureIndicator,
                    accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "internal"),
                )
            },
            actionBlock: { [weak self] in
                self?.didTapInternalSettings()
            },
        ))

        return section
    }

    // MARK: Group sections

    private func buildGroupMembershipSection(groupModel: TSGroupModel, sectionIndex: Int) -> OWSTableSection {
        let section = OWSTableSection()
        section.separatorInsetLeading = Self.cellHInnerMargin + CGFloat(AvatarBuilder.smallAvatarSizePoints) + ContactCellView.avatarTextHSpacing

        let groupMembership = groupModel.groupMembership

        // "Add Members" cell.
        if canEditConversationMembership, !isTerminatedGroup {
            section.add(OWSTableItem(customCellBlock: { [weak self] in
                guard let self else {
                    owsFailDebug("Missing self")
                    return OWSTableItem.newCell()
                }
                let cell = OWSTableItem.newCell()
                cell.preservesSuperviewLayoutMargins = true
                cell.contentView.preservesSuperviewLayoutMargins = true

                let iconView = OWSTableItem.buildIconInCircleView(
                    icon: .groupInfoAddMembers,
                    iconSize: AvatarBuilder.smallAvatarSizePoints,
                    innerIconSize: 20,
                    iconTintColor: Theme.primaryTextColor,
                )

                let rowLabel = UILabel()
                rowLabel.text = OWSLocalizedString(
                    "CONVERSATION_SETTINGS_ADD_MEMBERS",
                    comment: "Label for 'add members' button in conversation settings view.",
                )
                rowLabel.textColor = Theme.primaryTextColor
                rowLabel.font = OWSTableItem.primaryLabelFont
                rowLabel.lineBreakMode = .byTruncatingTail

                let contentRow = UIStackView(arrangedSubviews: [iconView, rowLabel])
                contentRow.spacing = self.iconSpacingSmall

                cell.contentView.addSubview(contentRow)
                contentRow.autoPinWidthToSuperviewMargins()
                contentRow.autoPinHeightToSuperview(withMargin: 7)

                return cell
            }, actionBlock: { [weak self] in
                self?.showAddMembersView()
            }))
        }

        let totalMemberCount = sortedGroupMembers.count

        let format: String
        if isTerminatedGroup {
            format = OWSLocalizedString(
                "CONVERSATION_SETTINGS_FORMER_MEMBERS_SECTION_TITLE_%d",
                tableName: "PluralAware",
                comment: "Format for the section title of the 'members' section in conversation settings view after a group has been terminated. Embeds: {{ the number of former group members }}.",
            )
        } else {
            format = OWSLocalizedString(
                "CONVERSATION_SETTINGS_MEMBERS_SECTION_TITLE_%d",
                tableName: "PluralAware",
                comment: "Format for the section title of the 'members' section in conversation settings view. Embeds: {{ the number of group members }}.",
            )
        }
        section.headerTitle = String.localizedStringWithFormat(format, totalMemberCount)

        var membersToRender = sortedGroupMembers

        let maxMembersToShow = 6
        let hasMoreMembers = !isShowingAllGroupMembers && membersToRender.count > maxMembersToShow
        if hasMoreMembers {
            membersToRender = Array(membersToRender.prefix(maxMembersToShow - 1))
        }

        let groupNameColors = GroupNameColors.forThread(self.thread)

        for memberAddress in membersToRender {
            guard let verificationState = groupMemberStateMap[memberAddress] else {
                owsFailDebug("Missing verificationState.")
                continue
            }

            let isLocalUser = memberAddress.isLocalAddress

            var memberLabel: MemberLabelForRendering?
            if
                let memberAci = memberAddress.aci,
                let memberLabelString = groupModel.groupMembership.memberLabel(for: memberAci)?.labelForRendering()
            {
                memberLabel = MemberLabelForRendering(
                    label: memberLabelString,
                    groupNameColor: groupNameColors.color(
                        for: memberAddress.aci,
                    ),
                )
            }

            let showAddMemberLabel = isLocalUser && memberLabel == nil && self.groupViewHelper.canEditMemberLabels && !isTerminatedGroup

            section.add(OWSTableItem(customCellBlock: { [weak self] in
                guard let self else {
                    owsFailDebug("Missing self")
                    return OWSTableItem.newCell()
                }
                let tableView = self.tableView
                guard let cell = tableView.dequeueReusableCell(withIdentifier: ContactTableViewCell.reuseIdentifier) as? ContactTableViewCell else {
                    owsFailDebug("Missing cell.")
                    return UITableViewCell()
                }

                SSKEnvironment.shared.databaseStorageRef.read { transaction in
                    let configuration = ContactCellConfiguration(address: memberAddress, localUserDisplayMode: .asLocalUser)
                    let isGroupAdmin = groupMembership.isFullMemberAndAdministrator(memberAddress)
                    let isVerified = verificationState == .verified
                    let isNoLongerVerified = verificationState == .noLongerVerified
                    let isBlocked = SSKEnvironment.shared.blockingManagerRef.isAddressBlocked(memberAddress, transaction: transaction)
                    if isGroupAdmin {
                        configuration.accessoryMessage = OWSLocalizedString(
                            "GROUP_MEMBER_ADMIN_INDICATOR",
                            comment: "Label indicating that a group member is an admin.",
                        )
                    } else if isNoLongerVerified {
                        configuration.accessoryMessage = OWSLocalizedString(
                            "CONTACT_CELL_IS_NO_LONGER_VERIFIED",
                            comment: "An indicator that a contact is no longer verified.",
                        )
                    } else if isBlocked {
                        configuration.accessoryMessage = MessageStrings.conversationIsBlocked
                    }

                    if isLocalUser {
                        cell.selectionStyle = .none
                    } else {
                        cell.selectionStyle = .default
                    }

                    if BuildFlags.MemberLabel.display, let memberLabel {
                        configuration.memberLabel = memberLabel
                    }

                    if showAddMemberLabel {
                        configuration.attributedSubtitle = NSAttributedString(
                            string: OWSLocalizedString(
                                "MEMBER_LABEL_ADD_CSVC",
                                comment: "Label that shows up under a local user's row in contacts prompting them to add a member label",
                            ),
                            attributes: [.font: UIFont.dynamicTypeCaption1Clamped.medium()],
                        ) + SignalSymbol.chevronRight.attributedString(
                            dynamicTypeBaseSize: 10,
                            weight: .bold,
                            leadingCharacter: .space,
                            attributes: [.foregroundColor: UIColor.Signal.secondaryLabel],
                        )
                    } else if isVerified {
                        configuration.useVerifiedSubtitle()
                    } else if
                        !memberAddress.isLocalAddress,
                        let bioForDisplay = SSKEnvironment.shared.profileManagerImplRef.userProfile(for: memberAddress, tx: transaction)?.bioForDisplay
                    {
                        configuration.attributedSubtitle = NSAttributedString(string: bioForDisplay)
                    } else {
                        owsAssertDebug(configuration.attributedSubtitle == nil)
                    }

                    let isSystemContact = SSKEnvironment.shared.contactManagerRef.fetchSignalAccount(for: memberAddress, transaction: transaction) != nil
                    configuration.shouldShowContactIcon = isSystemContact

                    cell.configure(configuration: configuration, transaction: transaction)
                }

                return cell
            }, actionBlock: { [weak self] in
                if showAddMemberLabel {
                    self?.memberLabelCoordinator?.presenter = self
                    self?.memberLabelCoordinator?.present()
                } else {
                    self?.didSelectGroupMember(memberAddress)
                }
            }))
        }

        if hasMoreMembers {
            let offset = canEditConversationMembership && !isTerminatedGroup ? 1 : 0
            let expandedMemberIndices = ((membersToRender.count + offset)..<(totalMemberCount + offset)).map {
                IndexPath(row: $0, section: sectionIndex)
            }

            section.add(OWSTableItem(
                customCellBlock: { [weak self] in
                    guard let self else {
                        owsFailDebug("Missing self")
                        return OWSTableItem.newCell()
                    }
                    let cell = OWSTableItem.newCell()
                    cell.preservesSuperviewLayoutMargins = true
                    cell.contentView.preservesSuperviewLayoutMargins = true

                    let iconView = OWSTableItem.buildIconInCircleView(
                        icon: .groupInfoShowAllMembers,
                        iconSize: AvatarBuilder.smallAvatarSizePoints,
                        innerIconSize: 20,
                        iconTintColor: Theme.primaryTextColor,
                    )

                    let rowLabel = UILabel()
                    rowLabel.text = CommonStrings.seeAllButton
                    rowLabel.textColor = Theme.primaryTextColor
                    rowLabel.font = OWSTableItem.primaryLabelFont
                    rowLabel.lineBreakMode = .byTruncatingTail

                    let contentRow = UIStackView(arrangedSubviews: [iconView, rowLabel])
                    contentRow.spacing = self.iconSpacingSmall

                    cell.contentView.addSubview(contentRow)
                    contentRow.autoPinWidthToSuperviewMargins()
                    contentRow.autoPinHeightToSuperview(withMargin: 7)

                    return cell
                },
                actionBlock: { [weak self] in
                    self?.showAllGroupMembers(revealingIndices: expandedMemberIndices)
                },
            ))
        }

        return section
    }

    private func buildGroupSettingsSection(
        groupModelV2: TSGroupModelV2,
        contents: OWSTableContents,
    ) {
        let section = OWSTableSection()

        let groupLinkStatus = (
            groupModelV2.isGroupInviteLinkEnabled
                ? CommonStrings.switchOn
                : CommonStrings.switchOff,
        )
        section.add(
            OWSTableItem.disclosureItem(
                icon: .groupInfoGroupLink,
                withText: OWSLocalizedString(
                    "CONVERSATION_SETTINGS_GROUP_LINK",
                    comment: "Label for 'group link' action in conversation settings view.",
                ),
                accessoryText: groupLinkStatus,
                actionBlock: { [weak self] in
                    self?.showGroupLinkView()
                },
            ),
        )

        if BuildFlags.MemberLabel.send {
            let canEditMemberLabel = groupViewHelper.canEditMemberLabels
            let iconColor: UIColor
            let textColor: UIColor
            if canEditMemberLabel {
                iconColor = Theme.primaryIconColor
                textColor = Theme.primaryTextColor
            } else {
                iconColor = UIColor.Signal.label.withAlphaComponent(0.3)
                textColor = UIColor.Signal.label.withAlphaComponent(0.3)
            }
            section.add(
                OWSTableItem.disclosureItem(
                    icon: .memberLabel,
                    tintColor: iconColor,
                    withText: OWSLocalizedString(
                        "CONVERSATION_SETTINGS_MEMBER_TAG",
                        comment: "Label for 'member label' action in conversation settings view.",
                    ),
                    textColor: textColor,
                    actionBlock: { [weak self] in
                        guard let self else { return }
                        if canEditMemberLabel {
                            memberLabelCoordinator?.presenter = self
                            memberLabelCoordinator?.present()
                        } else {
                            presentToast(
                                text: OWSLocalizedString(
                                    "MEMBER_LABEL_ADMIN_ONLY_WARNING_TOAST",
                                    comment: "Toast indicating that only admins can set a member label.",
                                ),
                            )
                        }
                    },
                ),
            )
        }

        let itemTitle = OWSLocalizedString(
            "CONVERSATION_SETTINGS_MEMBER_REQUESTS_AND_INVITES",
            comment: "Label for 'member requests & invites' action in conversation settings view.",
        )
        let invitedOrRequestingCount = groupModelV2.groupMembership.invitedMembers.count + groupModelV2.groupMembership.requestingMembers.count
        section.add(
            OWSTableItem.disclosureItem(
                icon: .groupInfoRequestAndInvites,
                withText: itemTitle,
                accessoryText: OWSFormat.formatInt(invitedOrRequestingCount),
                actionBlock: { [weak self] in
                    self?.showMemberRequestsAndInvitesView()
                },
            ),
        )

        if canEditPermissions {
            let itemTitle = OWSLocalizedString(
                "CONVERSATION_SETTINGS_PERMISSIONS",
                comment: "Label for 'permissions' action in conversation settings view.",
            )
            section.add(OWSTableItem.disclosureItem(
                icon: .groupInfoPermissions,
                withText: itemTitle,
                actionBlock: { [weak self] in
                    self?.showPermissionsSettingsView()
                },
            ))
        }

        contents.add(section)
    }

    private func buildMutualGroupsSection(sectionIndex: Int) -> OWSTableSection {
        let section = OWSTableSection()
        section.separatorInsetLeading = Self.cellHInnerMargin + CGFloat(AvatarBuilder.smallAvatarSizePoints) + ContactCellView.avatarTextHSpacing

        // "Add to a Group" cell.
        section.add(OWSTableItem(
            customCellBlock: { [weak self] in
                guard let self else {
                    owsFailDebug("Missing self")
                    return OWSTableItem.newCell()
                }
                let cell = OWSTableItem.newCell()
                cell.preservesSuperviewLayoutMargins = true
                cell.contentView.preservesSuperviewLayoutMargins = true

                let iconView = OWSTableItem.buildIconInCircleView(
                    icon: .groupInfoAddMembers,
                    iconSize: AvatarBuilder.smallAvatarSizePoints,
                    innerIconSize: 20,
                    iconTintColor: Theme.primaryTextColor,
                )

                let rowLabel = UILabel()
                rowLabel.text = OWSLocalizedString("ADD_TO_GROUP_TITLE", comment: "Title of the 'add to group' view.")
                rowLabel.textColor = Theme.primaryTextColor
                rowLabel.font = OWSTableItem.primaryLabelFont
                rowLabel.lineBreakMode = .byTruncatingTail

                let contentRow = UIStackView(arrangedSubviews: [iconView, rowLabel])
                contentRow.spacing = self.iconSpacingSmall

                cell.contentView.addSubview(contentRow)
                contentRow.autoPinWidthToSuperviewMargins()
                contentRow.autoPinHeightToSuperview(withMargin: 7)

                return cell
            },
            actionBlock: { [weak self] in
                self?.showAddToGroupView()
            },
        ))

        if mutualGroupThreads.count > 0 {
            let headerFormat = OWSLocalizedString(
                "CONVERSATION_SETTINGS_MUTUAL_GROUPS_SECTION_TITLE_%d",
                tableName: "PluralAware",
                comment: "Format for the section title of the 'mutual groups' section in conversation settings view. Embeds: {{ the number of shared groups }}.",
            )
            section.headerTitle = String.localizedStringWithFormat(headerFormat, mutualGroupThreads.count)
        } else {
            section.headerTitle = OWSLocalizedString(
                "CONVERSATION_SETTINGS_NO_MUTUAL_GROUPS_SECTION_TITLE",
                comment: "Section title of the 'mutual groups' section in conversation settings view when the contact shares no mutual groups.",
            )
        }

        let maxGroupsToShow = 6
        let hasMoreGroups = !isShowingAllMutualGroups && mutualGroupThreads.count > maxGroupsToShow
        let groupThreadsToRender: [TSGroupThread]
        if hasMoreGroups {
            groupThreadsToRender = Array(mutualGroupThreads.prefix(maxGroupsToShow - 1))
        } else {
            groupThreadsToRender = mutualGroupThreads
        }

        for groupThread in groupThreadsToRender {
            section.add(OWSTableItem(
                customCellBlock: {
                    let cell = GroupTableViewCell()
                    cell.configure(thread: groupThread)
                    return cell
                },
                actionBlock: {
                    SignalApp.shared.presentConversationForThread(
                        threadUniqueId: groupThread.uniqueId,
                        animated: true,
                    )
                },
            ))
        }

        if hasMoreGroups {
            let expandedGroupIndices = ((groupThreadsToRender.count + 1)..<(mutualGroupThreads.count + 1)).map {
                IndexPath(row: $0, section: sectionIndex)
            }

            section.add(OWSTableItem(
                customCellBlock: { [weak self] in
                    guard let self else {
                        owsFailDebug("Missing self")
                        return OWSTableItem.newCell()
                    }
                    let cell = OWSTableItem.newCell()
                    cell.preservesSuperviewLayoutMargins = true
                    cell.contentView.preservesSuperviewLayoutMargins = true

                    let iconView = OWSTableItem.buildIconInCircleView(
                        icon: .groupInfoShowAllMembers,
                        iconSize: AvatarBuilder.smallAvatarSizePoints,
                        innerIconSize: 20,
                        iconTintColor: Theme.primaryTextColor,
                    )

                    let rowLabel = UILabel()
                    rowLabel.text = CommonStrings.seeAllButton
                    rowLabel.textColor = Theme.primaryTextColor
                    rowLabel.font = OWSTableItem.primaryLabelFont
                    rowLabel.lineBreakMode = .byTruncatingTail

                    let contentRow = UIStackView(arrangedSubviews: [iconView, rowLabel])
                    contentRow.spacing = self.iconSpacingSmall

                    cell.contentView.addSubview(contentRow)
                    contentRow.autoPinWidthToSuperviewMargins()
                    contentRow.autoPinHeightToSuperview(withMargin: 7)

                    return cell
                },
                actionBlock: { [weak self] in
                    self?.showAllMutualGroups(revealingIndices: expandedGroupIndices)
                },
            ))
        }

        return section
    }

    private func buildTerminatedGroupSettingsSection(contents: OWSTableContents) {
        let section = OWSTableSection()

        let itemName: String
        if threadViewModel.isArchived {
            itemName = OWSLocalizedString(
                "CONVERSATION_SETTINGS_UNARCHIVE_CHAT",
                comment: "Label for 'unarchive chat' action in conversation settings view.",
            )
        } else {
            itemName = OWSLocalizedString(
                "CONVERSATION_SETTINGS_ARCHIVE_CHAT",
                comment: "Label for 'archive chat' action in conversation settings view.",
            )
        }

        section.add(OWSTableItem(
            customCellBlock: {
                return OWSTableItem.buildCell(
                    icon: .contextMenuArchive,
                    itemName: itemName,
                )
            },
            actionBlock: { [weak self] in
                self?.conversationSettingsViewDelegate?.toggleConversationArchived()
            },
        ))

        section.add(OWSTableItem(
            customCellBlock: {
                return OWSTableItem.buildCell(
                    icon: .contextMenuDelete,
                    itemName: OWSLocalizedString(
                        "CONVERSATION_SETTINGS_DELETE_CHAT",
                        comment: "Label for 'delete chat' action in conversation settings view.",
                    ),
                    customColor: UIColor.ows_accentRed,
                )
            },
            actionBlock: { [weak self] in
                self?.conversationSettingsViewDelegate?.deleteConversation()
            },
        ))

        contents.add(section)
    }
}