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

import ContactsUI
import SignalServiceKit
import SignalUI

struct ProfileSheetSheetCoordinator {
    private let address: SignalServiceAddress
    private let groupViewHelper: GroupViewHelper?
    private let spoilerState: SpoilerRenderState
    private let memberLabel: MemberLabelForRendering?

    init(
        address: SignalServiceAddress,
        groupViewHelper: GroupViewHelper?,
        spoilerState: SpoilerRenderState,
        memberLabel: MemberLabelForRendering? = nil,
    ) {
        self.address = address
        self.groupViewHelper = groupViewHelper
        self.spoilerState = spoilerState
        self.memberLabel = memberLabel
    }

    /// Present a ``MemberActionSheet`` for other users, and present a
    /// ``ContactAboutSheet`` for the local user.
    func presentAppropriateSheet(from viewController: UIViewController) {
        let threadViewModel = MemberActionSheet.fetchThreadViewModel(address: address)
        let thread = threadViewModel.threadRecord

        if thread.isNoteToSelf, let contactThread = thread as? TSContactThread {
            ContactAboutSheet(thread: contactThread, spoilerState: spoilerState, memberLabel: memberLabel, groupViewHelper: groupViewHelper)
                .present(from: viewController)
            return
        }

        let sheet = MemberActionSheet(
            threadViewModel: threadViewModel,
            address: address,
            groupViewHelper: groupViewHelper,
            spoilerState: spoilerState,
            memberLabel: memberLabel,
        )
        if viewController.overrideUserInterfaceStyle == .dark {
            sheet.overrideUserInterfaceStyle = .dark
            sheet.tableViewController.forceDarkMode = true
        }
        sheet.present(from: viewController)
    }
}

class MemberActionSheet: OWSTableSheetViewController {
    override var sheetBackgroundColor: UIColor {
        if #available(iOS 26, *) {
            .clear
        } else {
            super.sheetBackgroundColor
        }
    }

    private var groupViewHelper: GroupViewHelper?

    var avatarView: ConversationAvatarView?
    var thread: TSThread { threadViewModel.threadRecord }
    var threadViewModel: ThreadViewModel
    let address: SignalServiceAddress
    let spoilerState: SpoilerRenderState
    let memberLabel: MemberLabelForRendering?

    fileprivate init(
        threadViewModel: ThreadViewModel,
        address: SignalServiceAddress,
        groupViewHelper: GroupViewHelper?,
        spoilerState: SpoilerRenderState,
        memberLabel: MemberLabelForRendering?,
    ) {
        self.threadViewModel = threadViewModel
        self.groupViewHelper = groupViewHelper
        self.address = address
        self.spoilerState = spoilerState
        self.memberLabel = memberLabel

        if #available(iOS 26.0, *) {
            super.init(visualEffect: UIGlassEffect())
            self.topCornerRadius = 40
            self.tableViewController.backgroundStyle = .clear
        } else {
            super.init()
        }

        tableViewController.defaultSeparatorInsetLeading =
            OWSTableViewController2.cellHInnerMargin + 24 + OWSTableItem.iconSpacing

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(recipientUpdated(notification:)),
            name: .OWSContactsManagerSignalAccountsDidChange,
            object: nil,
        )
    }

    fileprivate static func fetchThreadViewModel(address: SignalServiceAddress) -> ThreadViewModel {
        // Avoid opening a write transaction if we can
        guard
            let threadViewModel: ThreadViewModel = SSKEnvironment.shared.databaseStorageRef.read(block: { transaction in
                guard
                    let thread = TSContactThread.getWithContactAddress(
                        address,
                        transaction: transaction,
                    ) else { return nil }
                return ThreadViewModel(
                    thread: thread,
                    forChatList: false,
                    transaction: transaction,
                )
            })
        else {
            return SSKEnvironment.shared.databaseStorageRef.write { transaction in
                let thread = TSContactThread.getOrCreateThread(
                    withContactAddress: address,
                    transaction: transaction,
                )
                return ThreadViewModel(
                    thread: thread,
                    forChatList: false,
                    transaction: transaction,
                )
            }
        }
        return threadViewModel
    }

    private weak var fromViewController: UIViewController?

    fileprivate func present(from viewController: UIViewController) {
        fromViewController = viewController
        viewController.present(self, animated: true)
    }

    override func tableContents() -> OWSTableContents {
        let contents = OWSTableContents()

        let topSpacerSection = OWSTableSection()
        topSpacerSection.customHeaderHeight = 12
        contents.add(topSpacerSection)

        let section = OWSTableSection()
        contents.add(section)

        section.customHeaderView = ConversationHeaderBuilder.buildHeader(
            for: thread,
            sizeClass: .eighty,
            options: [.message, .videoCall, .audioCall, .noBackground],
            memberLabel: memberLabel,
            delegate: self,
        )

        // If the local user, show no options.
        guard !address.isLocalAddress else {
            return contents
        }

        // Nickname
        section.add(.item(
            icon: .buttonEdit,
            tintColor: .Signal.label,
            name: OWSLocalizedString(
                "NICKNAME_BUTTON_TITLE",
                comment: "Title for the table cell in conversation settings for presenting the profile nickname editor.",
            ),
            textColor: .Signal.label,
            actionBlock: { [weak self] in
                guard let self else { return }
                let db = DependenciesBridge.shared.db

                let nicknameEditor = db.read { tx in
                    NicknameEditorViewController.create(
                        for: self.address,
                        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)
            },
        ))

        let isBlocked = threadViewModel.isBlocked
        if isBlocked {
            section.add(.item(
                icon: .chatSettingsBlock,
                tintColor: .Signal.label,
                name: OWSLocalizedString(
                    "BLOCK_LIST_UNBLOCK_BUTTON",
                    comment: "Button label for the 'unblock' button",
                ),
                textColor: .Signal.label,
                actionBlock: { [weak self] in
                    self?.didTapUnblockThread {}
                },
            ))
        } else {
            section.add(.item(
                icon: .chatSettingsBlock,
                tintColor: .Signal.label,
                name: OWSLocalizedString(
                    "BLOCK_LIST_BLOCK_BUTTON",
                    comment: "Button label for the 'block' button",
                ),
                textColor: .Signal.label,
                actionBlock: { [weak self] in
                    guard let self, let fromViewController = self.fromViewController else { return }
                    self.dismiss(animated: true) {
                        BlockListUIUtils.showBlockAddressActionSheet(
                            self.address,
                            from: fromViewController,
                            completion: nil,
                        )
                    }
                },
            ))
        }

        if let groupViewHelper = self.groupViewHelper, groupViewHelper.isFullOrInvitedMember(address) {
            if groupViewHelper.canRemoveFromGroup(address: address) {
                section.add(.item(
                    icon: .groupMemberRemoveFromGroup,
                    tintColor: .Signal.label,
                    name: OWSLocalizedString(
                        "CONVERSATION_SETTINGS_REMOVE_FROM_GROUP_BUTTON",
                        comment: "Label for 'remove from group' button in conversation settings view.",
                    ),
                    textColor: .Signal.label,
                    actionBlock: { [weak self] in
                        guard let self else { return }
                        self.dismiss(animated: true) {
                            self.groupViewHelper?.presentRemoveFromGroupActionSheet(address: self.address)
                        }
                    },
                ))
            }
            if groupViewHelper.memberActionSheetCanMakeGroupAdmin(address: address), !isBlocked {
                section.add(.item(
                    icon: .groupMemberMakeGroupAdmin,
                    tintColor: .Signal.label,
                    name: OWSLocalizedString(
                        "CONVERSATION_SETTINGS_MAKE_GROUP_ADMIN_BUTTON",
                        comment: "Label for 'make group admin' button in conversation settings view.",
                    ),
                    textColor: .Signal.label,
                    actionBlock: { [weak self] in
                        guard let self else { return }
                        self.dismiss(animated: true) {
                            self.groupViewHelper?.memberActionSheetMakeGroupAdminWasSelected(address: self.address)
                        }
                    },
                ))
            }
            if groupViewHelper.memberActionSheetCanRevokeGroupAdmin(address: address) {
                section.add(.item(
                    icon: .groupMemberRevokeGroupAdmin,
                    tintColor: .Signal.label,
                    name: OWSLocalizedString(
                        "CONVERSATION_SETTINGS_REVOKE_GROUP_ADMIN_BUTTON",
                        comment: "Label for 'revoke group admin' button in conversation settings view.",
                    ),
                    textColor: .Signal.label,
                    actionBlock: { [weak self] in
                        guard let self else { return }
                        self.dismiss(animated: true) {
                            self.groupViewHelper?.memberActionSheetRevokeGroupAdminWasSelected(
                                address: self.address,
                                hasMemberLabel: self.memberLabel != nil,
                            )
                        }
                    },
                ))
            }
        }

        if !isBlocked {
            section.add(.item(
                icon: .groupMemberAddToGroup,
                tintColor: .Signal.label,
                name: OWSLocalizedString(
                    "ADD_TO_GROUP",
                    comment: "Label for button or row which allows users to add to another group.",
                ),
                textColor: .Signal.label,
                actionBlock: { [weak self] in
                    guard let self, let fromViewController = self.fromViewController else { return }
                    self.dismiss(animated: true) {
                        AddToGroupViewController.presentForUser(self.address, from: fromViewController)
                    }
                },
            ))
        }

        let isSystemContact = SSKEnvironment.shared.databaseStorageRef.read { tx in
            return SSKEnvironment.shared.contactManagerRef.fetchSignalAccount(for: address, transaction: tx) != nil
        }
        if !isBlocked, isSystemContact {
            section.add(.item(
                icon: .contactInfoUserInContacts,
                tintColor: .Signal.label,
                name: OWSLocalizedString(
                    "CONVERSATION_SETTINGS_VIEW_IS_SYSTEM_CONTACT",
                    comment: "Indicates that user is in the system contacts list.",
                ),
                textColor: .Signal.label,
                actionBlock: { [weak self] in
                    guard let self else { return }
                    self.viewSystemContactDetails(contactAddress: self.address)
                },
            ))
        } else if !isBlocked, address.phoneNumber != nil {
            section.add(.item(
                icon: .contactInfoAddToContacts,
                tintColor: .Signal.label,
                name: OWSLocalizedString(
                    "CONVERSATION_SETTINGS_ADD_TO_SYSTEM_CONTACTS",
                    comment: "button in conversation settings view.",
                ),
                textColor: .Signal.label,
                actionBlock: { [weak self] in
                    guard let self else { return }
                    self.showAddToSystemContactsActionSheet(contactAddress: self.address)
                },
            ))
        }

        if !isBlocked {
            section.add(.item(
                icon: .contactInfoSafetyNumber,
                tintColor: .Signal.label,
                name: OWSLocalizedString(
                    "VERIFY_PRIVACY",
                    comment: "Label for button or row which allows users to verify the safety number of another user.",
                ),
                textColor: .Signal.label,
                actionBlock: { [weak self] in
                    guard let self, let fromViewController = self.fromViewController else { return }
                    self.dismiss(animated: true) {
                        FingerprintViewController.present(for: self.address.aci, from: fromViewController)
                    }
                },
            ))
        }

        return contents
    }

    private func viewSystemContactDetails(contactAddress: SignalServiceAddress) {
        guard let viewController = fromViewController else { return }
        let contactsViewHelper = SUIEnvironment.shared.contactsViewHelperRef

        dismiss(animated: true) {
            contactsViewHelper.presentSystemContactsFlow(
                CreateOrEditContactFlow(address: contactAddress, editImmediately: false),
                from: viewController,
            )
        }
    }

    private func showAddToSystemContactsActionSheet(contactAddress: SignalServiceAddress) {
        guard let viewController = fromViewController else { return }
        let contactsViewHelper = SUIEnvironment.shared.contactsViewHelperRef

        dismiss(animated: true) {
            let actionSheet = ActionSheetController()
            let createNewTitle = OWSLocalizedString(
                "CONVERSATION_SETTINGS_NEW_CONTACT",
                comment: "Label for 'new contact' button in conversation settings view.",
            )
            actionSheet.addAction(ActionSheetAction(
                title: createNewTitle,
                style: .default,
                handler: { _ in
                    contactsViewHelper.presentSystemContactsFlow(
                        CreateOrEditContactFlow(address: contactAddress),
                        from: viewController,
                    )
                },
            ))

            let addToExistingTitle = OWSLocalizedString(
                "CONVERSATION_SETTINGS_ADD_TO_EXISTING_CONTACT",
                comment: "Label for 'new contact' button in conversation settings view.",
            )
            actionSheet.addAction(ActionSheetAction(
                title: addToExistingTitle,
                style: .default,
                handler: { _ in
                    contactsViewHelper.presentSystemContactsFlow(
                        AddToExistingContactFlow(address: contactAddress),
                        from: viewController,
                    )
                },
            ))
            actionSheet.addAction(OWSActionSheets.cancelAction)

            viewController.presentActionSheet(actionSheet)
        }
    }

    @objc
    private func recipientUpdated(notification: NSNotification) {
        guard self.isViewLoaded else { return }
        AssertIsOnMainThread()
        updateTableContents()
    }
}

extension MemberActionSheet: ConversationHeaderDelegate {
    var isGroupV1Thread: Bool { groupViewHelper?.isGroupV1Thread == true }

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

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

    func didTapBadge() {
        guard avatarView != nil else { return }
        let (profile, shortName) = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            return (
                SSKEnvironment.shared.profileManagerRef.userProfile(for: address, tx: transaction),
                SSKEnvironment.shared.contactManagerRef.displayName(for: address, tx: transaction).resolvedValue(useShortNameIfAvailable: true),
            )
        }
        guard let primaryBadge = profile?.primaryBadge?.badge else { return }
        let owner: BadgeDetailsSheet.Owner
        if address.isLocalAddress {
            owner = .local(shortName: shortName)
        } else {
            owner = .remote(shortName: shortName)
        }
        let badgeSheet = BadgeDetailsSheet(focusedBadge: primaryBadge, owner: owner)
        present(badgeSheet, animated: true, completion: nil)
    }

    func tappedConversationSearch() {}
    func didTapUnblockThread(completion: @escaping () -> Void) {
        guard let fromViewController else { return }
        dismiss(animated: true) {
            BlockListUIUtils.showUnblockAddressActionSheet(
                self.address,
                from: fromViewController,
            ) { _ in
                completion()
            }
        }
    }

    func tappedButton() {
        dismiss(animated: true)
    }

    func didTapAddGroupDescription() {}
    var canEditConversationAttributes: Bool { false }
    var groupDescriptionDelegate: GroupDescriptionViewControllerDelegate? { nil }

    var canTapThreadName: Bool { true }

    func didTapThreadName() {
        guard let contactThread = self.thread as? TSContactThread else {
            owsFailDebug("How is member sheet not showing a contact?")
            return
        }
        let sheet = ContactAboutSheet(thread: contactThread, spoilerState: spoilerState)
        dismiss(animated: true) {
            guard let fromViewController = self.fromViewController else { return }
            sheet.present(from: fromViewController)
        }
    }

    func didTapMemberLabel() {
        guard
            BuildFlags.MemberLabel.send,
            let presenter = self.fromViewController as? MemberLabelViewControllerPresenter,
            let groupViewHelper,
            let groupThread = groupViewHelper.thread as? TSGroupThread,
            let groupModel = groupThread.groupModel as? TSGroupModelV2,
            let memberLabelCoordinator = groupViewHelper.memberLabelCoordinator
        else {
            return
        }

        let localUserHasMemberLabel = groupModel.groupMembership.localUserMemberLabel != nil
        dismiss(animated: true) {
            memberLabelCoordinator.presenter = presenter
            memberLabelCoordinator.presentWithEducationSheet(
                localUserHasMemberLabel: localUserHasMemberLabel,
                canEditMemberLabel: groupViewHelper.canEditMemberLabels,
            )
        }
    }
}

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