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

import SignalServiceKit
import SignalUI

extension ChatListViewController {

    // MARK: - multi select mode

    func willEnterMultiselectMode() {
        willEnterMultiselectMode(cancelCurrentEditAction: true)
    }

    func willEnterMultiselectMode(cancelCurrentEditAction: Bool) {
        AssertIsOnMainThread()

        guard !viewState.multiSelectState.isActive else {
            return
        }

        // multi selection does not work well with displaying search results, so let's clear the search for now
        searchBar.delegate?.searchBarCancelButtonClicked?(searchBar)
        viewState.multiSelectState.title = title
        if viewState.chatListMode == .inbox {
            let doneButton: UIBarButtonItem = .cancelButton { [weak self] in
                self?.done()
            }
            navigationItem.setLeftBarButton(doneButton, animated: true)
            navigationItem.setRightBarButtonItems(nil, animated: true)
        } else {
            owsAssertDebug(navigationItem.rightBarButtonItem != nil, "can't change label of right bar button")
            navigationItem.rightBarButtonItem?.title = CommonStrings.doneButton
            navigationItem.rightBarButtonItem?.accessibilityHint = CommonStrings.doneButton
        }
        searchBar.isUserInteractionEnabled = false
        searchBar.alpha = 0.5
        viewState.multiSelectState.setIsActive(true, tableView: tableView, cancelCurrentEditAction: cancelCurrentEditAction)
        showToolbar()
        loadCoordinator.loadIfNecessary(shouldForceLoad: true)
    }

    func leaveMultiselectMode() {
        AssertIsOnMainThread()

        guard viewState.multiSelectState.isActive else {
            return
        }

        if viewState.chatListMode == .archive {
            owsAssertDebug(navigationItem.rightBarButtonItem != nil, "can't change label of right bar button")
            navigationItem.rightBarButtonItem?.title = CommonStrings.selectButton
            navigationItem.rightBarButtonItem?.accessibilityHint = CommonStrings.selectButton
        }
        searchBar.isUserInteractionEnabled = true
        searchBar.alpha = 1
        viewState.multiSelectState.setIsActive(false, tableView: tableView)
        title = viewState.multiSelectState.title
        hideToolbar()
        loadCoordinator.loadIfNecessary(shouldForceLoad: true)

        if let lastViewedThreadUniqueId, isConversationActive(threadUniqueId: lastViewedThreadUniqueId) {
            ensureSelectedThreadUniqueId(lastViewedThreadUniqueId, animated: false)
        }
    }

    func showToolbar() {
        AssertIsOnMainThread()

        if #available(iOS 26, *) {
            self.updateCaptions()
            self.navigationController?.setToolbarHidden(false, animated: true)
            (self.tabBarController as? HomeTabBarController)?.setTabBarHidden(true)
            return
        }

        if viewState.multiSelectState.toolbar == nil {
            let tbc = BlurredToolbarContainer()
            tbc.alpha = 0
            view.addSubview(tbc)
            tbc.autoPinWidthToSuperview()
            tbc.autoPinEdge(toSuperviewEdge: .bottom)
            viewState.multiSelectState.toolbar = tbc
            let animateToolbar = {
                // Hack to get the toolbar to update its safe area correctly after any
                // tab bar hidden state changes. Unclear why this is needed or why it needs
                // to be async, but without it the toolbar inherits stale safe area insets from
                // its parent, and its own safe area doesn't line up.
                DispatchQueue.main.async {
                    self.viewState.multiSelectState.toolbar?.toolbar.setItems(
                        self.makeToolbarButtons(),
                        animated: false,
                    )
                }
                UIView.animate(withDuration: 0.25, animations: {
                    tbc.alpha = 1
                }) { [weak self] _ in
                    self?.tableView.contentSize.height += tbc.height
                }
            }
            if
                viewState.chatListMode == .inbox,
                let tabController = self.tabBarController as? HomeTabBarController
            {
                tabController.setTabBarHidden(true, animated: true, duration: 0.1) { _ in
                    animateToolbar()
                }
            } else {
                animateToolbar()
            }
        }
        updateCaptions()
    }

    @objc
    func switchMultiSelectState(_ sender: UIBarButtonItem) {
        AssertIsOnMainThread()

        if viewState.multiSelectState.isActive {
            leaveMultiselectMode()
        } else {
            willEnterMultiselectMode()
        }
    }

    // MARK: - theme changes

    func applyThemeToContextMenuAndToolbar() {
        viewState.multiSelectState.toolbar?.themeChanged()
    }

    // MARK: private helper

    private func done() {
        updateCaptions()
        leaveMultiselectMode()
        updateBarButtonItems()
        updateViewState()
        if viewState.chatListMode == .archive {
            navigationItem.rightBarButtonItem?.title = CommonStrings.selectButton
        }
    }

    private func makeToolbarButtons() -> [UIBarButtonItem] {
        let hasSelectedEntries = !(tableView.indexPathsForSelectedRows ?? []).isEmpty

        let archiveBtn = UIBarButtonItem(
            title: viewState.chatListMode == .archive ? CommonStrings.unarchiveAction : CommonStrings.archiveAction,
            style: .plain,
            target: self,
            action: #selector(performArchive),
        )
        if #available(iOS 26, *) {
            archiveBtn.image = UIImage(resource: .archive)
        }
        archiveBtn.isEnabled = hasSelectedEntries

        let readButton: UIBarButtonItem
        if hasSelectedEntries {
            readButton = UIBarButtonItem(title: CommonStrings.readAction, style: .plain, target: self, action: #selector(performRead))
            readButton.isEnabled = false
            for path in tableView.indexPathsForSelectedRows ?? [] {
                if let thread = tableDataSource.threadViewModel(forIndexPath: path), thread.hasUnreadMessages {
                    readButton.isEnabled = true
                    break
                }
            }
        } else {
            func hasUnreadEntry(threadUniqueIds: [String]) -> Bool {
                return threadUniqueIds.contains {
                    tableDataSource.threadViewModel(threadUniqueId: $0).hasUnreadMessages
                }
            }

            readButton = UIBarButtonItem(
                title: OWSLocalizedString(
                    "HOME_VIEW_TOOLBAR_READ_ALL",
                    comment: "Title 'Read All' button in the toolbar of the ChatList if multi-section is active.",
                ),
                style: .plain,
                target: self,
                action: #selector(performReadAll),
            )
            readButton.isEnabled = false
            readButton.isEnabled = readButton.isEnabled || hasUnreadEntry(threadUniqueIds: renderState.pinnedThreadUniqueIds)
            readButton.isEnabled = readButton.isEnabled || hasUnreadEntry(threadUniqueIds: renderState.unpinnedThreadUniqueIds)
        }

        let deleteBtn = UIBarButtonItem(title: CommonStrings.deleteButton, style: .plain, target: self, action: #selector(performDelete))
        if #available(iOS 26, *) {
            deleteBtn.image = UIImage(resource: .trash)
        }
        deleteBtn.isEnabled = hasSelectedEntries

        var entries: [UIBarButtonItem] = []
        for button in [archiveBtn, readButton, deleteBtn] {
            if !entries.isEmpty {
                entries.append(.flexibleSpace())
            }
            entries.append(button)
        }
        return entries
    }

    private func hideToolbar() {
        AssertIsOnMainThread()

        if #available(iOS 26, *) {
            (self.tabBarController as? HomeTabBarController)?.setTabBarHidden(false)
            self.navigationController?.setToolbarHidden(true, animated: true)
            return
        }

        if let toolbar = viewState.multiSelectState.toolbar {
            UIView.animate(withDuration: 0.25) { [weak self] in
                toolbar.alpha = 0
                if let tableView = self?.tableView {
                    // remove the extra space for the toolbar if necessary
                    tableView.contentSize.height = tableView.sizeThatFitsMaxSize.height
                }
            } completion: { [weak self] _ in
                toolbar.removeFromSuperview()
                self?.viewState.multiSelectState.toolbar = nil
                if
                    self?.viewState.chatListMode == .inbox,
                    let tabController = self?.tabBarController as? HomeTabBarController
                {
                    tabController.setTabBarHidden(false, animated: true, duration: 0.1)
                }
            }
        }
    }

    public func updateCaptions() {
        AssertIsOnMainThread()

        let count = tableView.indexPathsForSelectedRows?.count ?? 0
        if count == 0 {
            title = viewState.multiSelectState.title
        } else {
            let format = OWSLocalizedString(
                "MESSAGE_ACTIONS_TOOLBAR_CAPTION_%d",
                tableName: "PluralAware",
                comment: "Label for the toolbar used in the multi-select mode. The number of selected items (1 or more) is passed.",
            )
            title = String.localizedStringWithFormat(format, count)
        }

        if #available(iOS 26, *) {
            toolbarItems = makeToolbarButtons()
        } else {
            viewState.multiSelectState.toolbar?.toolbar.setItems(
                makeToolbarButtons(),
                animated: false,
            )
        }
    }

    // MARK: toolbar button actions

    /// Archives or unarchives the selected threads
    @objc
    func performArchive() {
        performOn(indexPaths: tableView.indexPathsForSelectedRows ?? []) { threadViewModels in
            for threadViewModel in threadViewModels {
                toggleThreadIsArchived(threadViewModel: threadViewModel)
            }
        }
        done()
    }

    @objc
    func performRead() {
        performOn(indexPaths: tableView.indexPathsForSelectedRows ?? []) { threadViewModels in
            for threadViewModel in threadViewModels {
                markThreadAsRead(threadViewModel: threadViewModel)
            }
        }
        done()
    }

    @objc
    func performReadAll() {
        let threadUniqueIds = renderState.pinnedThreadUniqueIds + renderState.unpinnedThreadUniqueIds
        let threadViewModels = threadUniqueIds.compactMap { threadUniqueId in
            let threadViewModel = tableDataSource.threadViewModel(threadUniqueId: threadUniqueId)
            return threadViewModel.hasUnreadMessages ? threadViewModel : nil
        }

        performOn(threadViewModels: threadViewModels) { threadViewModels in
            for threadViewModel in threadViewModels {
                markThreadAsRead(threadViewModel: threadViewModel)
            }
        }
        done()
    }

    @objc
    func performDelete() {
        AssertIsOnMainThread()

        guard !(tableView.indexPathsForSelectedRows ?? []).isEmpty else {
            return
        }

        let db = DependenciesBridge.shared.db
        let threadSoftDeleteManager = DependenciesBridge.shared.threadSoftDeleteManager

        /// We need to grab these now, since they'll be `nil`-ed out when we
        /// show the modal spinner below.
        let selectedIndexPaths = tableView.indexPathsForSelectedRows ?? []

        let title: String
        let message: String
        let labelFormat = OWSLocalizedString(
            "CONVERSATION_DELETE_CONFIRMATIONS_ALERT_TITLE_%d",
            tableName: "PluralAware",
            comment: "Title for the 'conversations delete confirmation' alert for multiple messages. Embeds: {{ %@ the number of currently selected items }}.",
        )
        title = String.localizedStringWithFormat(labelFormat, selectedIndexPaths.count)
        let messageFormat = OWSLocalizedString(
            "CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGES_%d",
            tableName: "PluralAware",
            comment: "Message for the 'conversations delete confirmation' alert for multiple messages.",
        )
        message = String.localizedStringWithFormat(messageFormat, selectedIndexPaths.count)

        let alert = ActionSheetController(title: title, message: message)
        alert.addAction(ActionSheetAction(
            title: CommonStrings.deleteButton,
            style: .destructive,
        ) { [weak self] _ in
            guard let self else { return }

            // This deletion can be quite intensive, so we'll wrap the whole
            // thing in a UI-blocking modal.
            ModalActivityIndicatorViewController.present(
                fromViewController: self,
                title: CommonStrings.deletingModal,
                canCancel: false,
            ) { modal in
                // We want to protect this whole operation with a single write
                // transaction, to ensure the contents of the threads don't
                // change as we're deleting them.
                db.write { transaction in
                    self.performOn(indexPaths: selectedIndexPaths) { threadViewModels in
                        threadSoftDeleteManager.softDelete(
                            threads: threadViewModels.map { $0.threadRecord },
                            sendDeleteForMeSyncMessage: true,
                            tx: transaction,
                        )
                    }
                }
                DispatchQueue.main.async {
                    modal.dismiss()
                }
            }

            self.done()
        })
        alert.addAction(OWSActionSheets.cancelAction)

        presentActionSheet(alert)
    }

    private func performOn(indexPaths: [IndexPath], action: ([ThreadViewModel]) -> Void) {
        let threadViewModels = indexPaths.compactMap(tableDataSource.threadViewModel(forIndexPath:))
        performOn(threadViewModels: threadViewModels, action: action)
    }

    private func performOn(threadViewModels: [ThreadViewModel], action: ([ThreadViewModel]) -> Void) {
        guard !threadViewModels.isEmpty else { return }

        viewState.multiSelectState.actionPerformed = true
        action(threadViewModels)
    }
}

// MARK: - object encapsulating the complete state of the MultiSelect process

public class MultiSelectState {

    static let multiSelectionModeDidChange = Notification.Name("multiSelectionModeDidChange")

    fileprivate var title: String?
    fileprivate var toolbar: BlurredToolbarContainer?
    private var _isActive = false
    var actionPerformed = false
    var locked = false

    var isActive: Bool { return _isActive }

    fileprivate func setIsActive(_ active: Bool, tableView: UITableView? = nil, cancelCurrentEditAction: Bool = true) {
        if active != _isActive {
            AssertIsOnMainThread()

            _isActive = active
            // turn off current edit mode if necessary (removes leading and trailing actions)
            if let tableView, active && tableView.isEditing && cancelCurrentEditAction {
                tableView.setEditing(false, animated: true)
            }
            if active || !actionPerformed {
                tableView?.setEditing(active, animated: true)
            } else if let tableView {
                // The animation of unsetting the setEditing flag will be performed
                // in the tableView.beginUpdates/endUpdates block (called in applyPartialLoadResult).
                // This results in a nice combined animation.
                // The following code is usually not needed and serves only as an
                // emergency exit if the provided mechanism does not work.
                DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1) {
                    if tableView.isEditing {
                        tableView.setEditing(active, animated: false)
                    }
                }
            }
            actionPerformed = false
            NotificationCenter.default.post(name: Self.multiSelectionModeDidChange, object: active)
        }
    }
}