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

import Foundation
import SignalServiceKit
public import SignalUI

public enum SafetyTipsType {
    case contact
    case group
}

public protocol SafetyTipsViewControllerDelegate: AnyObject {
    func didTapViewMoreSafetyTips()
}

public class SafetyTipsViewController: InteractiveSheetViewController, UIScrollViewDelegate {
    private enum SafetyTips: CaseIterable {
        case chatsFromSignal
        case reviewNames
        case scams

        var image: UIImage? {
            switch self {
            case .chatsFromSignal:
                return UIImage(resource: .safetytip4801)
            case .reviewNames:
                return UIImage(resource: .safetytip4802)
            case .scams:
                return UIImage(resource: .safetytip4803)
            }
        }

        var title: String {
            switch self {
            case .chatsFromSignal:
                return OWSLocalizedString(
                    "SAFETY_TIPS_SIGNAL_CHATS_TITLE",
                    comment: "Message title describing the signal chats tip.",
                )
            case .reviewNames:
                return OWSLocalizedString(
                    "SAFETY_TIPS_REVIEW_NAMES_TITLE",
                    comment: "Message title describing the review names safety tip.",
                )
            case .scams:
                return OWSLocalizedString(
                    "SAFETY_TIPS_LOOK_OUT_FOR_SCAMS_TITLE",
                    comment: "Message title describing the scams safety tip.",
                )
            }
        }

        var body: String {
            switch self {
            case .chatsFromSignal:
                return OWSLocalizedString(
                    "SAFETY_TIPS_SIGNAL_CHATS_BODY",
                    comment: "Message body describing the signal chats tip.",
                )
            case .reviewNames:
                return OWSLocalizedString(
                    "SAFETY_TIPS_REVIEW_NAMES_BODY",
                    comment: "Message body describing the review names safety tip.",
                )
            case .scams:
                return OWSLocalizedString(
                    "SAFETY_TIPS_LOOK_OUT_FOR_SCAMS_BODY",
                    comment: "Message body describing the scams safety tip.",
                )
            }
        }
    }

    let contentScrollView = UIScrollView()
    let stackView = UIStackView()

    public weak var delegate: SafetyTipsViewControllerDelegate?

    override public func viewDidLoad() {
        super.viewDidLoad()

        minimizedHeight = min(612, CurrentAppContext().frame.height)
        super.allowsExpansion = false

        let header = UILabel()
        header.text = OWSLocalizedString(
            "SAFETY_TIPS_HEADER_TITLE",
            comment: "Title for Safety Tips education screen.",
        )
        header.font = .dynamicTypeHeadline
        header.textAlignment = .center
        header.isAccessibilityElement = true
        header.accessibilityTraits.insert(.header)
        contentView.addSubview(header)
        header.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            header.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
            header.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor, constant: 16),
        ])

        contentView.addSubview(contentScrollView)
        contentScrollView.addSubview(stackView)

        stackView.axis = .vertical
        stackView.spacing = 20

        contentScrollView.translatesAutoresizingMaskIntoConstraints = false
        stackView.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            contentScrollView.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 24),
            contentScrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -90),
            contentScrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            contentScrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),

            stackView.topAnchor.constraint(equalTo: contentScrollView.contentLayoutGuide.topAnchor),
            stackView.bottomAnchor.constraint(equalTo: contentScrollView.contentLayoutGuide.bottomAnchor),
            stackView.leadingAnchor.constraint(equalTo: contentScrollView.contentLayoutGuide.leadingAnchor, constant: 24),
            stackView.trailingAnchor.constraint(equalTo: contentScrollView.contentLayoutGuide.trailingAnchor, constant: 24),
            stackView.widthAnchor.constraint(equalTo: contentScrollView.frameLayoutGuide.widthAnchor, constant: -48),
        ])

        for bullet in SafetyTips.allCases {
            let bulletView = SafetyBulletView(bullet)
            stackView.addArrangedSubview(bulletView)
        }

        var config = UIButton.Configuration.filled()
        config.baseBackgroundColor = UIColor.Signal.secondaryFill
        config.cornerStyle = .capsule
        var attrString = AttributedString(CommonStrings.viewMoreButton)
        attrString.font = .dynamicTypeBodyClamped.medium()
        config.attributedTitle = attrString
        config.baseForegroundColor = UIColor.Signal.label
        config.contentInsets = .init(margin: 14)
        let viewMoreButton = UIButton(
            configuration: config,
            primaryAction: .init(handler: { [weak self] _ in
                self?.dismiss(animated: true)
                self?.delegate?.didTapViewMoreSafetyTips()
            }),
        )

        viewMoreButton.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(viewMoreButton)
        NSLayoutConstraint.activate([
            viewMoreButton.bottomAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.bottomAnchor),
            viewMoreButton.leadingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.leadingAnchor, constant: 20),
            viewMoreButton.trailingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.trailingAnchor, constant: -20),
            viewMoreButton.heightAnchor.constraint(equalToConstant: 52),
        ])
    }

    private class SafetyBulletView: UIStackView {
        init(_ bullet: SafetyTips) {
            super.init(frame: .zero)

            self.axis = .horizontal
            self.alignment = .firstBaseline
            self.spacing = 24
            self.isLayoutMarginsRelativeArrangement = true
            self.layoutMargins = .zero

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

            let headerLabel = UILabel()
            headerLabel.text = bullet.title
            headerLabel.numberOfLines = 0
            headerLabel.textColor = UIColor.Signal.label
            headerLabel.font = .dynamicTypeBody.semibold()
            textStack.addArrangedSubview(headerLabel)

            let bodyLabel = UILabel()
            bodyLabel.text = bullet.body
            bodyLabel.numberOfLines = 0
            bodyLabel.textColor = UIColor.Signal.secondaryLabel
            bodyLabel.font = .dynamicTypeBody
            textStack.addArrangedSubview(bodyLabel)

            let bulletPoint = UIImageView(image: bullet.image)
            bulletPoint.contentMode = .scaleAspectFit
            bulletPoint.translatesAutoresizingMaskIntoConstraints = false
            bulletPoint.widthAnchor.constraint(equalToConstant: 48).isActive = true
            bulletPoint.heightAnchor.constraint(equalToConstant: 48).isActive = true

            addArrangedSubview(bulletPoint)
            addArrangedSubview(textStack)
        }

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

public class MoreSafetyTipsViewController: InteractiveSheetViewController, UIScrollViewDelegate {
    let contentScrollView = UIScrollView()
    override public var interactiveScrollViews: [UIScrollView] { [contentScrollView] }
    override public var sheetBackgroundColor: UIColor { Theme.tableView2PresentedBackgroundColor }

    private enum Constants {
        static let stackSpacing: CGFloat = 12.0

        static let outerSpacing: CGFloat = 20.0
        static let outerMargins: UIEdgeInsets = .init(hMargin: 24.0, vMargin: 0.0)

        static let footerSpacing: CGFloat = 16.0

        static let buttonDiameter: CGFloat = 52.0
        static let buttonMargin: CGFloat = 24.0
    }

    fileprivate enum MoreSafetyTips: CaseIterable {
        case chatsFromSignal
        case reviewNames
        case vagueMessages
        case messagesWithLinks
        case crypto
        case fakeBusiness

        var image: UIImage? {
            switch self {
            case .chatsFromSignal:
                return UIImage(resource: .safetytip24001)
            case .reviewNames:
                return UIImage(resource: .safetytip24002)
            case .vagueMessages:
                return UIImage(resource: .safetytip24003)
            case .messagesWithLinks:
                return UIImage(resource: .safetytip24004)
            case .crypto:
                return UIImage(resource: .safetytip24005)
            case .fakeBusiness:
                return UIImage(resource: .safetytip24006)
            }
        }

        var title: String {
            switch self {
            case .chatsFromSignal:
                return OWSLocalizedString(
                    "SAFETY_TIPS_SIGNAL_CHATS_TITLE",
                    comment: "Message title describing the signal chats tip.",
                )
            case .reviewNames:
                return OWSLocalizedString(
                    "SAFETY_TIPS_REVIEW_NAMES_TITLE",
                    comment: "Message title describing the review names safety tip.",
                )
            case .vagueMessages:
                return OWSLocalizedString(
                    "SAFETY_TIPS_VAGUE_MESSAGE_TITLE",
                    comment: "Message title describing the safety tip about vague messages.",
                )
            case .messagesWithLinks:
                return OWSLocalizedString(
                    "SAFETY_TIPS_MESSAGE_LINKS_TITLE",
                    comment: "Message title describing the safety tip about unknown links in messages.",
                )
            case .crypto:
                return OWSLocalizedString(
                    "SAFETY_TIPS_CRYPTO_TITLE",
                    comment: "Message title describing the crypto safety tip.",
                )
            case .fakeBusiness:
                return OWSLocalizedString(
                    "SAFETY_TIPS_FAKE_BUSINESS_TITLE",
                    comment: "Message title describing the safety tip about unknown or fake businesses.",
                )
            }
        }

        var body: String {
            switch self {
            case .chatsFromSignal:
                return OWSLocalizedString(
                    "SAFETY_TIPS_SIGNAL_CHATS_BODY_VIEW_MORE",
                    comment: "Message body describing the signal chats tip in the 'view more' flow.",
                )
            case .reviewNames:
                return OWSLocalizedString(
                    "SAFETY_TIPS_REVIEW_NAMES_BODY_VIEW_MORE",
                    comment: "Message body describing the review names safety tip in the 'view more' flow.",
                )
            case .vagueMessages:
                return OWSLocalizedString(
                    "SAFETY_TIPS_VAGUE_MESSAGE_BODY",
                    comment: "Message contents for the vague message safety tip.",
                )
            case .messagesWithLinks:
                return OWSLocalizedString(
                    "SAFETY_TIPS_MESSAGE_LINKS_BODY",
                    comment: "Message contents for the unknown links in messages safety tip.",
                )
            case .crypto:
                return OWSLocalizedString(
                    "SAFETY_TIPS_CRYPTO_BODY",
                    comment: "Message contents for the crypto safety tip.",
                )
            case .fakeBusiness:
                return OWSLocalizedString(
                    "SAFETY_TIPS_FAKE_BUSINESS_BODY",
                    comment: "Message contents for the safety tip concerning fake businesses.",
                )
            }
        }
    }

    var prefersNavigationBarHidden: Bool { true }

    override public func viewDidLoad() {
        super.viewDidLoad()

        minimizedHeight = min(510, CurrentAppContext().frame.height)
        super.allowsExpansion = false

        contentView.addSubview(contentScrollView)
        contentScrollView.addSubview(tipScrollView)

        tipScrollView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            tipScrollView.topAnchor.constraint(equalTo: contentScrollView.topAnchor),
            tipScrollView.bottomAnchor.constraint(equalTo: contentScrollView.bottomAnchor),
            tipScrollView.leadingAnchor.constraint(equalTo: contentScrollView.leadingAnchor),
            tipScrollView.trailingAnchor.constraint(equalTo: contentScrollView.trailingAnchor),
            tipScrollView.widthAnchor.constraint(equalTo: contentScrollView.frameLayoutGuide.widthAnchor),
        ])

        contentScrollView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            contentScrollView.topAnchor.constraint(equalTo: contentView.topAnchor),
            contentScrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -84),
            contentScrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            contentScrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            contentScrollView.widthAnchor.constraint(equalTo: contentView.widthAnchor),
        ])

        buildContents()
        updateButtonState()
        setColorsForCurrentTheme()
    }

    override public func themeDidChange() {
        super.themeDidChange()
        buildContents()
        updateButtonState()
        setColorsForCurrentTheme()
    }

    // MARK: - Views

    private lazy var tipScrollView: UIScrollView = {
        let scrollView = UIScrollView(frame: .zero)
        scrollView.isPagingEnabled = true
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.delegate = self
        return scrollView
    }()

    private lazy var pageControl: UIPageControl = {
        let pageControl = UIPageControl()
        pageControl.numberOfPages = MoreSafetyTips.allCases.count
        pageControl.currentPage = 0
        pageControl.addTarget(self, action: #selector(self.changePage), for: .valueChanged)
        return pageControl
    }()

    private lazy var previousTipButton: UIButton = {
        let previousButton = UIButton(type: .system)
        var config = UIButton.Configuration.filled()
        config.baseForegroundColor = UIColor.Signal.label
        config.baseBackgroundColor = UIColor.Signal.primaryFill
        config.image = UIImage(resource: .chevronLeft26)
        config.cornerStyle = .capsule
        previousButton.accessibilityLabel = CommonStrings.backButton
        previousButton.configuration = config
        previousButton.addTarget(self, action: #selector(didTapPrevious), for: .touchUpInside)

        return previousButton

    }()

    private lazy var nextTipButton: UIButton = {
        let nextButton = UIButton(type: .system)
        var config = UIButton.Configuration.filled()
        config.baseForegroundColor = UIColor.Signal.label
        config.baseBackgroundColor = UIColor.Signal.primaryFill
        config.image = UIImage(resource: .chevronRight26)
        config.cornerStyle = .capsule
        nextButton.configuration = config
        nextButton.accessibilityLabel = CommonStrings.nextButton
        nextButton.addTarget(self, action: #selector(didTapNext), for: .touchUpInside)

        return nextButton
    }()

    private lazy var footerView: UIView = {
        let stackView = UIStackView(arrangedSubviews: [
            previousTipButton,
            pageControl,
            nextTipButton,
        ])

        nextTipButton.translatesAutoresizingMaskIntoConstraints = false
        previousTipButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            nextTipButton.widthAnchor.constraint(equalToConstant: Constants.buttonDiameter),
            nextTipButton.heightAnchor.constraint(equalToConstant: Constants.buttonDiameter),
            previousTipButton.widthAnchor.constraint(equalToConstant: Constants.buttonDiameter),
            previousTipButton.heightAnchor.constraint(equalToConstant: Constants.buttonDiameter),
        ])

        let container = UIView()
        container.addSubview(stackView)
        container.backgroundColor = sheetBackgroundColor
        container.tintColor = sheetBackgroundColor

        stackView.axis = .horizontal
        stackView.spacing = Constants.footerSpacing
        stackView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            stackView.centerXAnchor.constraint(equalTo: container.centerXAnchor),
            stackView.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -Constants.buttonMargin),
            stackView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Constants.buttonMargin),
            stackView.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -Constants.buttonMargin),
        ])

        return container
    }()

    // MARK: - TableView

    private func buildContents() {
        prepareTipsScrollView()

        contentView.addSubview(footerView)
        footerView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            footerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            footerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            footerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            footerView.heightAnchor.constraint(equalToConstant: 84),
        ])
    }

    private func prepareTipsScrollView() {
        var priorView: UIView?
        tipScrollView.removeAllSubviews()
        MoreSafetyTips.allCases.forEach { tip in
            let view = SafetyTipView(safetyTip: tip)
            tipScrollView.addSubview(view)

            view.autoPinHeight(toHeightOf: tipScrollView)
            view.autoPinWidth(toWidthOf: tipScrollView)
            if let priorView {
                view.autoPinEdge(.leading, to: .trailing, of: priorView)
            } else {
                view.autoPinEdge(.leading, to: .leading, of: tipScrollView)
            }
            priorView = view
        }
        priorView?.autoPinEdge(.trailing, to: .trailing, of: tipScrollView)
    }

    // MARK: - ScrollViewDelegate

    public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        let pageNumber = round(scrollView.contentOffset.x / scrollView.frame.size.width)
        pageControl.currentPage = Int(pageNumber)
        updateButtonState()
    }

    // MARK: - Actions

    @objc
    private func changePage() {
        let x = CGFloat(pageControl.currentPage) * tipScrollView.frame.size.width
        tipScrollView.setContentOffset(CGPoint(x: x, y: 0), animated: true)
        updateButtonState()

        let currentPageView = tipScrollView.subviews[pageControl.currentPage]
        DispatchQueue.main.async {
            UIAccessibility.post(notification: .layoutChanged, argument: currentPageView)
        }
    }

    @objc
    private func didTapPrevious() {
        pageControl.currentPage = max(pageControl.currentPage - 1, 0)
        changePage()
    }

    @objc
    private func didTapNext() {
        pageControl.currentPage = min(pageControl.currentPage + 1, pageControl.numberOfPages)
        changePage()
    }

    private func updateButtonState() {
        switch pageControl.currentPage {
        case 0:
            // hide previous, show next
            previousTipButton.alpha = 0
            previousTipButton.isUserInteractionEnabled = false
            nextTipButton.alpha = 1
            nextTipButton.isUserInteractionEnabled = true
        case pageControl.numberOfPages - 1:
            // show previous, hide next
            previousTipButton.alpha = 1
            previousTipButton.isUserInteractionEnabled = true
            nextTipButton.alpha = 0
            nextTipButton.isUserInteractionEnabled = false
        default:
            // show previous, show next
            previousTipButton.alpha = 1
            previousTipButton.isUserInteractionEnabled = true
            nextTipButton.alpha = 1
            nextTipButton.isUserInteractionEnabled = true
        }
    }

    private func setColorsForCurrentTheme() {
        pageControl.pageIndicatorTintColor = Theme.isDarkThemeEnabled ? .ows_gray65 : .ows_gray20
        pageControl.currentPageIndicatorTintColor = Theme.isDarkThemeEnabled ? .ows_gray20 : .ows_gray65
    }
}

extension MoreSafetyTipsViewController {
    class SafetyTipView: UIView {
        fileprivate init(safetyTip: MoreSafetyTips) {
            super.init(frame: .zero)
            layoutMargins = .init(hMargin: 24.0, vMargin: 0.0)

            let stackView = UIStackView()
            stackView.axis = .vertical
            self.addSubview(stackView)
            stackView.spacing = 12.0
            stackView.autoPinEdgesToSuperviewMargins()

            let imageView = UIImageView(image: safetyTip.image)
            imageView.contentMode = .scaleAspectFit

            stackView.addArrangedSubview(imageView)

            let titleLabel = UILabel()
            titleLabel.text = safetyTip.title
            titleLabel.numberOfLines = 0
            titleLabel.textAlignment = .center
            titleLabel.lineBreakMode = .byWordWrapping
            titleLabel.font = .dynamicTypeBody.bold()
            titleLabel.textColor = Theme.primaryTextColor
            stackView.addArrangedSubview(titleLabel)

            let messageLabel = UILabel()
            messageLabel.text = safetyTip.body
            messageLabel.numberOfLines = 0
            messageLabel.textAlignment = .center
            messageLabel.lineBreakMode = .byWordWrapping
            messageLabel.font = .dynamicTypeBodyClamped
            messageLabel.textColor = Theme.secondaryTextAndIconColor
            stackView.addArrangedSubview(messageLabel)
        }

        @available(*, unavailable, message: "Use other constructor")
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    }
}