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")
}
}
}