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

import SignalServiceKit
import SignalUI
import UIKit

// MARK: - ContactAboutSheet

class ContactAboutSheet: StackSheetViewController {
    struct Context {
        let contactManager: any ContactManager
        let identityManager: any OWSIdentityManager
        let recipientDatabaseTable: RecipientDatabaseTable
        let nicknameManager: any NicknameManager

        static let `default` = Context(
            contactManager: SSKEnvironment.shared.contactManagerRef,
            identityManager: DependenciesBridge.shared.identityManager,
            recipientDatabaseTable: DependenciesBridge.shared.recipientDatabaseTable,
            nicknameManager: DependenciesBridge.shared.nicknameManager,
        )
    }

    private let thread: TSContactThread
    private let isLocalUser: Bool
    private let spoilerState: SpoilerRenderState
    private let context: Context
    private let memberLabel: MemberLabelForRendering?
    private let groupViewHelper: GroupViewHelper?

    init(
        thread: TSContactThread,
        spoilerState: SpoilerRenderState,
        context: Context = .default,
        memberLabel: MemberLabelForRendering? = nil,
        groupViewHelper: GroupViewHelper? = nil,
    ) {
        self.thread = thread
        self.isLocalUser = thread.isNoteToSelf
        self.spoilerState = spoilerState
        self.context = context
        self.memberLabel = memberLabel
        self.groupViewHelper = groupViewHelper
        super.init()
        DependenciesBridge.shared.databaseChangeObserver.appendDatabaseChangeDelegate(self)
    }

    private weak var fromViewController: UIViewController?

    func present(
        from viewController: UIViewController,
        dismissalDelegate: (any SheetDismissalDelegate)? = nil,
    ) {
        self.fromViewController = viewController
        self.dismissalDelegate = dismissalDelegate
        viewController.present(self, animated: true)
    }

    // MARK: Layout

    private var nameLabel: ProfileDetailLabel?

    private lazy var avatarView: ConversationAvatarView = {
        let avatarView = ConversationAvatarView(
            sizeClass: .customDiameter(240),
            localUserDisplayMode: .asUser,
            badged: false,
        )
        avatarView.updateWithSneakyTransactionIfNecessary { config in
            config.dataSource = .thread(thread)
        }
        avatarView.interactionDelegate = self
        return avatarView
    }()

    private lazy var avatarViewContainer: UIView = {
        let container = UIView.container()
        container.addSubview(avatarView)
        avatarView.autoCenterInSuperview()
        avatarView.autoPinWidthToSuperview(relation: .lessThanOrEqual)
        avatarView.autoPinHeightToSuperview(relation: .lessThanOrEqual)
        return container
    }()

    // MARK: Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        stackView.spacing = 10
        stackView.alignment = .fill
        updateContents()
    }

    override func contentSizeCategoryDidChange() {
        super.contentSizeCategoryDidChange()
        loadContents()
    }

    override var stackViewInsets: UIEdgeInsets {
        let hMargin: CGFloat = {
            if UIDevice.current.isNarrowerThanIPhone6 {
                return 20
            } else {
                return 32
            }
        }()

        return .init(
            top: 24,
            leading: hMargin,
            bottom: 20,
            trailing: hMargin,
        )
    }

    override var minimumBottomInsetIncludingSafeArea: CGFloat { 32 }
    override var sheetBackgroundColor: UIColor {
        UIColor.Signal.secondaryBackground
    }

    override var handleBackgroundColor: UIColor {
        UIColor.Signal.transparentSeparator
    }

    // MARK: - Content

    /// Updates the contents with a database read and reloads the view.
    private func updateContents() {
        SSKEnvironment.shared.databaseStorageRef.read { tx in
            updateContactNames(tx: tx)
            updateIsVerified(tx: tx)
            updateProfileBio(tx: tx)
            updateConnectionState(tx: tx)
            updateIsInSystemContacts(tx: tx)
            updateMutualGroupThreadCount(tx: tx)
            updateNote(tx: tx)
        }

        loadContents()
    }

    /// Reloads the view content with the existing data.
    @MainActor
    private func loadContents() {
        stackView.removeAllSubviews()

        stackView.addArrangedSubview(avatarViewContainer)
        stackView.setCustomSpacing(16, after: avatarViewContainer)

        let titleLabel = UILabel()
        titleLabel.font = .dynamicTypeTitle2.semibold()
        if isLocalUser {
            titleLabel.text = CommonStrings.you
        } else {
            titleLabel.text = OWSLocalizedString(
                "CONTACT_ABOUT_SHEET_TITLE",
                comment: "The title for a contact 'about' sheet.",
            )
        }
        stackView.addArrangedSubview(titleLabel)
        stackView.setCustomSpacing(12, after: titleLabel)

        let nameLabel = ProfileDetailLabel.profile(
            displayName: self.displayName,
            secondaryName: self.secondaryName,
        ) { [weak self] in
            guard
                let self,
                let secondaryName = self.secondaryName,
                let nameLabel = self.nameLabel
            else { return }
            Tooltip(
                message: String.nonPluralLocalizedStringWithFormat(
                    OWSLocalizedString(
                        "CONTACT_ABOUT_SHEET_SECONDARY_NAME_TOOLTIP_MESSAGE",
                        comment: "Message for a tooltip that appears above a parenthesized name for another user, indicating that that name is the name the other user set for themself. Embeds {{name}}",
                    ),
                    secondaryName,
                ),
                shouldShowCloseButton: false,
            ).present(from: self, sourceView: nameLabel, arrowDirections: .down)
        }
        self.nameLabel = nameLabel
        stackView.addArrangedSubview(nameLabel)

        if !isInSystemContacts, !isVerified {
            let label = ProfileDetailLabel.profileNameEducation { [weak fromViewController, weak dismissalDelegate] in
                let sheet = NameEducationSheet(type: .contact)
                sheet.dismissalDelegate = dismissalDelegate
                fromViewController?.dismiss(animated: true) {
                    fromViewController?.present(sheet, animated: true)
                }
            }
            stackView.addArrangedSubview(label)
        }

        if
            isLocalUser,
            groupViewHelper?.canEditMemberLabels ?? false,
            let presenter = fromViewController as? MemberLabelViewControllerPresenter,
            !(groupViewHelper?.isTerminatedGroup ?? false)
        {
            stackView.addArrangedSubview(ProfileDetailLabel.memberLabel(memberLabel?.label, tapAction: { [weak self] in
                self?.groupViewHelper?.memberLabelCoordinator?.presenter = presenter
                self?.dismiss(animated: true, completion: {
                    self?.groupViewHelper?.memberLabelCoordinator?.present()
                })
            }))
        }

        if isVerified {
            stackView.addArrangedSubview(ProfileDetailLabel.verified())
        }

        if let profileBio {
            stackView.addArrangedSubview(ProfileDetailLabel.profileAbout(bio: profileBio))
        }

        switch connectionState {
        case .connection:
            stackView.addArrangedSubview(ProfileDetailLabel.signalConnectionLink(
                shouldDismissOnNavigation: true,
                presentEducationFrom: fromViewController,
                dismissalDelegate: dismissalDelegate,
            ))
        case .blocked:
            stackView.addArrangedSubview(ProfileDetailLabel.blocked(name: self.shortDisplayName))
        case .pending:
            stackView.addArrangedSubview(ProfileDetailLabel.pendingRequest(name: self.shortDisplayName))
        case .noConnection:
            stackView.addArrangedSubview(ProfileDetailLabel.noDirectChat(name: self.shortDisplayName))
        case nil:
            break
        }

        if isInSystemContacts {
            stackView.addArrangedSubview(ProfileDetailLabel.inSystemContacts(name: self.shortDisplayName))
        }

        let recipientAddress = thread.contactAddress
        if let phoneNumber = recipientAddress.phoneNumber {
            stackView.addArrangedSubview(ProfileDetailLabel.phoneNumber(phoneNumber, presentSuccessToastFrom: self))
        }

        if let mutualGroupThreads {
            stackView.addArrangedSubview(ProfileDetailLabel.mutualGroups(for: thread, mutualGroups: mutualGroupThreads))
        }

        if let note {
            let noteLabel = ProfileDetailLabel(
                title: note,
                icon: .contactInfoNote,
                showDetailDisclosure: true,
                shouldLineWrap: false,
                tapAction: { [weak self] in
                    self?.didTapNote()
                },
            )
            stackView.addArrangedSubview(noteLabel)
        }
    }

    // MARK: Name

    private var displayName: String = ""
    private var shortDisplayName: String = ""
    /// A secondary name to show after the primary name. Used to show a
    /// contact's profile name when it is overridden by a nickname.
    private var secondaryName: String?
    private func updateContactNames(tx: DBReadTransaction) {
        if isLocalUser {
            let profileManager = SSKEnvironment.shared.profileManagerRef
            let localUserProfile = profileManager.localUserProfile(tx: tx)
            self.displayName = localUserProfile?.filteredFullName ?? ""
            // contactShortName not needed for local user
            return
        }

        let displayName = self.context.contactManager.displayName(for: thread.contactAddress, tx: tx)
        self.displayName = displayName.resolvedValue()
        self.shortDisplayName = displayName.resolvedValue(useShortNameIfAvailable: true)

        if case .phoneNumber(let phoneNumber) = displayName {
            self.displayName = PhoneNumber.bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber(phoneNumber.stringValue)
        }

        switch displayName {
        case .nickname, .systemContactName:
            guard
                let profile = SSKEnvironment.shared.profileManagerRef.fetchUserProfiles(
                    for: [thread.contactAddress],
                    tx: tx,
                ).first,
                let profileName = profile?.nameComponents
                    .map(DisplayName.profileName(_:))?
                    .resolvedValue(),
                profileName != displayName.resolvedValue()
            else {
                fallthrough
            }
            self.secondaryName = profileName
        case .profileName, .phoneNumber, .username, .deletedAccount, .unknown:
            self.secondaryName = nil
        }
    }

    private func didTapNote() {
        self.dismiss(animated: true) { [weak fromViewController = self.fromViewController, thread = self.thread] in
            guard let fromViewController else { return }
            let noteSheet = ContactNoteSheet(
                thread: thread,
                context: .init(
                    db: DependenciesBridge.shared.db,
                    recipientDatabaseTable: self.context.recipientDatabaseTable,
                    nicknameManager: self.context.nicknameManager,
                ),
            )
            noteSheet.present(from: fromViewController)
        }
    }

    // MARK: Verified

    private var isVerified = false
    private func updateIsVerified(tx: DBReadTransaction) {
        isVerified = context.identityManager.verificationState(for: thread.contactAddress, tx: tx) == .verified
    }

    // MARK: Bio

    private var profileBio: String?
    private func updateProfileBio(tx: DBReadTransaction) {
        let profileManager = SSKEnvironment.shared.profileManagerRef
        let userProfile = profileManager.userProfile(for: thread.contactAddress, tx: tx)
        profileBio = userProfile?.bioForDisplay
    }

    // MARK: Connection

    private enum ConnectionState {
        case connection
        case blocked
        case pending
        case noConnection
    }

    private var connectionState: ConnectionState?
    private func updateConnectionState(tx: DBReadTransaction) {
        if isLocalUser {
            connectionState = nil
        } else if SSKEnvironment.shared.profileManagerRef.isThread(inProfileWhitelist: thread, transaction: tx) {
            connectionState = .connection
        } else if SSKEnvironment.shared.blockingManagerRef.isAddressBlocked(thread.contactAddress, transaction: tx) {
            connectionState = .blocked
        } else if thread.hasPendingMessageRequest(transaction: tx) {
            connectionState = .pending
        } else {
            connectionState = .noConnection
        }
    }

    // MARK: System contacts

    private var isInSystemContacts = false
    private func updateIsInSystemContacts(tx: DBReadTransaction) {
        if isLocalUser {
            isInSystemContacts = false
            return
        }
        isInSystemContacts = self.context.contactManager.fetchSignalAccount(for: thread.contactAddress, transaction: tx) != nil
    }

    // MARK: Threads

    private var mutualGroupThreads: [TSGroupThread]?
    private func updateMutualGroupThreadCount(tx: DBReadTransaction) {
        if isLocalUser {
            mutualGroupThreads = nil
            return
        }

        mutualGroupThreads = TSGroupThread.groupThreads(
            with: self.thread.contactAddress,
            transaction: tx,
        )
        .filter(\.groupModel.groupMembership.isLocalUserFullMember)
        .filter(\.shouldThreadBeVisible)
        .filter { !$0.isTerminatedGroup }
        // We don't want to show "no groups in common",
        // so return nil instead of an empty array.
        .nilIfEmpty
    }

    // MARK: Note

    private var note: String?
    private func updateNote(tx: DBReadTransaction) {
        guard
            let recipient = context.recipientDatabaseTable.fetchRecipient(
                address: thread.contactAddress,
                tx: tx,
            )
        else {
            self.note = nil
            return
        }
        let nicknameRecord = context.nicknameManager.fetchNickname(for: recipient, tx: tx)
        self.note = nicknameRecord?.note
    }
}

// MARK: - DatabaseChangeDelegate

extension ContactAboutSheet: DatabaseChangeDelegate {
    func databaseChangesDidUpdate(databaseChanges: SignalServiceKit.DatabaseChanges) {
        guard databaseChanges.didUpdate(thread: thread) else { return }
        updateContents()
    }

    func databaseChangesDidUpdateExternally() {
        updateContents()
    }

    func databaseChangesDidReset() {
        updateContents()
    }
}

// MARK: - ConversationAvatarViewDelegate

extension ContactAboutSheet: ConversationAvatarViewDelegate {
    func didTapBadge() {
        // Badges are not shown on contact about sheet
    }

    func presentStoryViewController() {
        let vc = StoryPageViewController(
            context: self.thread.storyContext,
            spoilerState: self.spoilerState,
        )
        present(vc, animated: true)
    }

    func presentAvatarViewController() {
        guard
            avatarView.primaryImage != nil,
            let vc = SSKEnvironment.shared.databaseStorageRef.read(block: { tx in
                AvatarViewController(
                    thread: self.thread,
                    renderLocalUserAsNoteToSelf: false,
                    readTx: tx,
                )
            })
        else {
            return
        }

        present(vc, animated: true)
    }
}

// MARK: - AvatarViewPresentationContextProvider

extension ContactAboutSheet: AvatarViewPresentationContextProvider {
    var conversationAvatarView: ConversationAvatarView? { avatarView }
}