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

import SignalServiceKit
import SignalUI
public import UIKit

public class MessageAction: NSObject {

    let block: (_ sender: Any?) -> Void
    let accessibilityIdentifier: String
    let contextMenuTitle: String
    let contextMenuAttributes: ContextMenuAction.Attributes

    public enum MessageActionType: CaseIterable {
        case reply
        case copy
        case info
        case delete
        case share
        case save
        case forward
        case select
        case speak
        case stopSpeaking
        case edit
        case showPaymentDetails
        case endPoll
        case pin
        case unpin

        /// Lower priority numbers indicate an action should be shown earlier.
        var priority: Int {
            return switch self {
            case .reply: 0
            case .forward: 1
            case .edit: 2
            case .copy: 3
            case .share: 4
            case .save: 5
            case .endPoll: 6
            case .select: 7
            case .showPaymentDetails: 8
            case .speak: 9
            case .stopSpeaking: 10
            case .info: 11
            case .pin: 12
            case .unpin: 13
            case .delete: 14
            }
        }
    }

    let actionType: MessageActionType

    public init(
        _ actionType: MessageActionType,
        accessibilityLabel: String,
        accessibilityIdentifier: String,
        contextMenuTitle: String,
        contextMenuAttributes: ContextMenuAction.Attributes,
        block: @escaping (_ sender: Any?) -> Void,
    ) {
        self.actionType = actionType
        self.accessibilityIdentifier = accessibilityIdentifier
        self.contextMenuTitle = contextMenuTitle
        self.contextMenuAttributes = contextMenuAttributes
        self.block = block
        super.init()
        self.accessibilityLabel = accessibilityLabel
    }

    var contextMenuIcon: UIImage {
        let icon: ThemeIcon = {
            switch actionType {
            case .reply:
                return .contextMenuReply
            case .copy:
                return .contextMenuCopy
            case .info:
                return .contextMenuInfo
            case .delete:
                return .contextMenuDelete
            case .share:
                return .contextMenuShare
            case .save:
                return .contextMenuSave
            case .forward:
                return .contextMenuForward
            case .select:
                return .contextMenuSelect
            case .speak:
                return .contextMenuSpeak
            case .stopSpeaking:
                return .contextMenuStopSpeaking
            case .edit:
                return .contextMenuEdit
            case .showPaymentDetails:
                return .settingsPayments
            case .endPoll:
                return .pollStopLight
            case .pin:
                return .pin
            case .unpin:
                return .unpin
            }
        }()
        return Theme.iconImage(icon)
    }

    var barButtonImage: UIImage {
        let icon: ThemeIcon = {
            switch actionType {
            case .delete:
                return .buttonDelete
            case .forward:
                return .buttonForward
            default:
                owsFail("Invalid icon")
            }
        }()
        return Theme.iconImage(icon)
    }
}

public protocol MessageActionsToolbarDelegate: AnyObject {
    func messageActionsToolbar(_ messageActionsToolbar: MessageActionsToolbar, executedAction: MessageAction)
    var messageActionsToolbarSelectedInteractionCount: Int { get }
}

public class MessageActionsToolbar: UIView {

    weak var actionDelegate: MessageActionsToolbarDelegate?

    enum Mode {
        case normal(messagesActions: [MessageAction])
        case selection(
            deleteMessagesAction: MessageAction,
            forwardMessagesAction: MessageAction,
        )
    }

    private let mode: Mode

    private let toolbar = UIToolbar()

    init(mode: Mode) {
        self.mode = mode

        super.init(frame: .zero)

        toolbar.translatesAutoresizingMaskIntoConstraints = false
        addSubview(toolbar)
        addConstraints([
            toolbar.topAnchor.constraint(equalTo: topAnchor),
            toolbar.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor),
            toolbar.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor),
            toolbar.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor),
        ])

        if #unavailable(iOS 26) {
            toolbar.setShadowImage(UIImage(), forToolbarPosition: .any)

            NotificationCenter.default.addObserver(
                self,
                selector: #selector(themeDidChange),
                name: .themeDidChange,
                object: nil,
            )
        }

        updateContent()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: -

    @objc
    @available(iOS, deprecated: 26)
    private func themeDidChange() {
        guard #unavailable(iOS 26) else { return }

        AssertIsOnMainThread()
        updateContent()
    }

    public func updateContent() {
        actionItems.removeAll()
        buildItems()
    }

    private func buildItems() {
        switch mode {
        case .normal(let messagesActions):
            buildNormalItems(messagesActions: messagesActions)
        case .selection(let deleteMessagesAction, let forwardMessagesAction):
            buildSelectionItems(
                deleteMessagesAction: deleteMessagesAction,
                forwardMessagesAction: forwardMessagesAction,
            )
        }
    }

    private var actionItems = [MessageAction.MessageActionType: UIBarButtonItem]()

    private func barButtonItem(for messageAction: MessageAction) -> UIBarButtonItem {
        let barButtonItem = UIBarButtonItem(
            image: messageAction.barButtonImage,
            primaryAction: UIAction { [weak self] _ in
                guard let self else { return }
                self.actionDelegate?.messageActionsToolbar(self, executedAction: messageAction)
            },
        )
        if #unavailable(iOS 26) {
            barButtonItem.tintColor = Theme.primaryIconColor
        }
        barButtonItem.accessibilityLabel = messageAction.accessibilityLabel
        return barButtonItem
    }

    private func buildNormalItems(messagesActions: [MessageAction]) {
        var newItems = [UIBarButtonItem]()

        for messageAction in messagesActions {
            if !newItems.isEmpty {
                newItems.append(.flexibleSpace())
            }

            let actionItem = barButtonItem(for: messageAction)
            newItems.append(actionItem)
            actionItems[messageAction.actionType] = actionItem
        }

        // If we only have a single button, center it.
        if newItems.count == 1 {
            newItems.insert(.flexibleSpace(), at: 0)
            newItems.append(.flexibleSpace())
        }

        toolbar.items = newItems
    }

    private func buildSelectionItems(
        deleteMessagesAction: MessageAction,
        forwardMessagesAction: MessageAction,
    ) {

        let deleteItem = barButtonItem(for: deleteMessagesAction)
        actionItems[deleteMessagesAction.actionType] = deleteItem

        let forwardItem = barButtonItem(for: forwardMessagesAction)
        actionItems[forwardMessagesAction.actionType] = forwardItem

        let selectedCount: Int = actionDelegate?.messageActionsToolbarSelectedInteractionCount ?? 0
        let labelFormat = 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.",
        )
        let labelTitle = String.localizedStringWithFormat(labelFormat, selectedCount)
        let label = UILabel()
        label.text = labelTitle
        if #available(iOS 26, *) {
            label.font = UIFont.dynamicTypeHeadlineClamped.monospaced()
        } else {
            label.font = UIFont.dynamicTypeBodyClamped.monospaced()
        }
        label.textColor = .Signal.label
        label.textAlignment = .center
        label.sizeToFit()
        let labelView: UIView = {
            // Add horizontal padding around text on iOS 26 because the item is displayed in a glass bubble.
            if #available(iOS 26, *) {
                let container = UIView()
                container.addSubview(label)
                label.translatesAutoresizingMaskIntoConstraints = false
                container.addConstraints([
                    label.topAnchor.constraint(equalTo: container.topAnchor),
                    label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 8),
                    label.centerXAnchor.constraint(equalTo: container.centerXAnchor),
                    label.bottomAnchor.constraint(equalTo: container.bottomAnchor),
                ])
                return container
            } else {
                return label
            }
        }()
        let labelItem = UIBarButtonItem(customView: labelView)
        labelItem.isEnabled = false

        toolbar.items = [
            deleteItem,
            .flexibleSpace(),
            labelItem,
            .flexibleSpace(),
            forwardItem,
        ]
    }

    public func buttonItem(for actionType: MessageAction.MessageActionType) -> UIBarButtonItem? {
        guard let buttonItem = actionItems[actionType] else {
            owsFailDebug("Missing action item: \(actionType).")
            return nil
        }
        return buttonItem
    }
}