405 lines
14 KiB
Swift
405 lines
14 KiB
Swift
//
|
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
@objc(OWSGetStartedBannerViewControllerDelegate)
|
|
protocol GetStartedBannerViewControllerDelegate: AnyObject {
|
|
func getStartedBannerDidTapInviteFriends(_ banner: GetStartedBannerViewController)
|
|
func getStartedBannerDidTapCreateGroup(_ banner: GetStartedBannerViewController)
|
|
func getStartedBannerDidTapAppearance(_ banner: GetStartedBannerViewController)
|
|
func getStartedBannerDidDismissAllCards(_ banner: GetStartedBannerViewController, animated: Bool)
|
|
func getStartedBannerDidTapAvatarBuilder(_ banner: GetStartedBannerViewController)
|
|
}
|
|
|
|
@objc(OWSGetStartedBannerViewController)
|
|
class GetStartedBannerViewController: UIViewController, UICollectionViewDelegateFlowLayout {
|
|
|
|
// MARK: - Views
|
|
|
|
private let header: UILabel = {
|
|
let label = UILabel()
|
|
label.font = UIFont.ows_dynamicTypeBodyClamped.ows_semibold
|
|
label.adjustsFontForContentSizeCategory = true
|
|
label.text = NSLocalizedString(
|
|
"GET_STARTED_BANNER_TITLE",
|
|
comment: "Title for the 'Get Started' banner")
|
|
return label
|
|
}()
|
|
|
|
private let collectionView: UICollectionView = {
|
|
let layout = UICollectionViewFlowLayout()
|
|
layout.scrollDirection = .horizontal
|
|
layout.itemSize = CGSize(width: 166, height: 178)
|
|
layout.minimumInteritemSpacing = 16
|
|
layout.sectionInsetReference = .fromLayoutMargins
|
|
|
|
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
|
view.backgroundColor = .clear
|
|
view.register(GetStartedBannerCell.self, forCellWithReuseIdentifier: GetStartedBannerCell.reuseIdentifier)
|
|
view.showsHorizontalScrollIndicator = false
|
|
view.alwaysBounceHorizontal = true
|
|
return view
|
|
}()
|
|
|
|
// Colors are updated in applyTheme()
|
|
private let opaqueBackdrop = UIView()
|
|
private let gradientBackdrop: GradientView = {
|
|
let gradient = GradientView(colors: [])
|
|
gradient.isUserInteractionEnabled = false
|
|
return gradient
|
|
}()
|
|
|
|
// MARK: - Data
|
|
|
|
@objc
|
|
public var hasIncompleteCards: Bool { bannerContent.count > 0 }
|
|
@objc
|
|
public var opaqueHeight: CGFloat { view.height - gradientBackdrop.height }
|
|
|
|
private weak var delegate: GetStartedBannerViewControllerDelegate?
|
|
private let threadFinder = AnyThreadFinder()
|
|
private var bannerContent: [GetStartedBannerEntry] = []
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
@objc
|
|
init(delegate: GetStartedBannerViewControllerDelegate) {
|
|
self.delegate = delegate
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
|
updateContent()
|
|
applyTheme()
|
|
|
|
SDSDatabaseStorage.shared.appendDatabaseChangeDelegate(self)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(applyTheme),
|
|
name: .ThemeDidChange,
|
|
object: nil)
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(activeCardsDidChange),
|
|
name: Self.activeCardsDidChange,
|
|
object: nil)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(localProfileDidChange),
|
|
name: .localProfileDidChange,
|
|
object: nil)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func loadView() {
|
|
let view = PassthroughView()
|
|
|
|
view.addSubview(gradientBackdrop)
|
|
view.addSubview(opaqueBackdrop)
|
|
view.addSubview(header)
|
|
view.addSubview(collectionView)
|
|
view.layoutMargins = UIEdgeInsets(top: 0, leading: 8, bottom: 8, trailing: 8)
|
|
|
|
gradientBackdrop.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom)
|
|
gradientBackdrop.autoSetDimension(.height, toSize: 40)
|
|
opaqueBackdrop.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .top)
|
|
opaqueBackdrop.autoPinEdge(.top, to: .bottom, of: gradientBackdrop)
|
|
|
|
header.autoPinLeadingToSuperviewMargin()
|
|
header.autoPinEdge(toSuperviewMargin: .trailing, relation: .lessThanOrEqual)
|
|
header.autoPinEdge(.top, to: .top, of: opaqueBackdrop, withOffset: 8)
|
|
|
|
collectionView.preservesSuperviewLayoutMargins = true
|
|
collectionView.autoSetDimension(.height, toSize: 180)
|
|
collectionView.autoPinEdge(.top, to: .bottom, of: header, withOffset: 12)
|
|
collectionView.autoPinWidthToSuperview()
|
|
collectionView.autoPinBottomToSuperviewMargin()
|
|
collectionView.clipsToBounds = false
|
|
|
|
self.view = view
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
collectionView.delegate = self
|
|
collectionView.dataSource = self
|
|
}
|
|
|
|
@objc func applyTheme() {
|
|
header.textColor = Theme.isDarkThemeEnabled ? .ows_gray05 : .ows_black
|
|
let backdropColor = Theme.backgroundColor
|
|
opaqueBackdrop.backgroundColor = backdropColor
|
|
|
|
if Theme.isDarkThemeEnabled {
|
|
gradientBackdrop.colors = [
|
|
(color: .clear, location: 0),
|
|
(color: backdropColor, location: 1)
|
|
]
|
|
} else {
|
|
gradientBackdrop.colors = [
|
|
(color: .ows_whiteAlpha00, location: 0),
|
|
(color: backdropColor, location: 1)
|
|
]
|
|
}
|
|
}
|
|
|
|
func fetchContent() -> [GetStartedBannerEntry] {
|
|
SDSDatabaseStorage.shared.read { readTx in
|
|
let activeCards = Self.getActiveCards(readTx: readTx)
|
|
|
|
let visibleThreadCount: UInt
|
|
do {
|
|
let unarchivedThreadCount = try self.threadFinder.visibleThreadCount(isArchived: false, transaction: readTx)
|
|
let archivedThreadCount = try self.threadFinder.visibleThreadCount(isArchived: true, transaction: readTx)
|
|
visibleThreadCount = unarchivedThreadCount + archivedThreadCount
|
|
} catch {
|
|
owsFailDebug("Failed to fetch thread count")
|
|
return []
|
|
}
|
|
|
|
// If we have five or more threads, dismiss all cards
|
|
if activeCards.count > 0, visibleThreadCount >= 5 {
|
|
Logger.info("User has more than five threads. Dismissing Get Started banner.")
|
|
SDSDatabaseStorage.shared.asyncWrite { writeTx in
|
|
Self.dismissAllCards(writeTx: writeTx)
|
|
}
|
|
return []
|
|
} else {
|
|
// Once you have an avatar, don't show the avatar builder card.
|
|
if Self.profileManager.localProfileAvatarData() != nil {
|
|
Self.databaseStorage.asyncWrite { writeTx in
|
|
Self.completeCard(.avatarBuilder, writeTx: writeTx)
|
|
}
|
|
return activeCards.filter { $0 != .avatarBuilder }
|
|
}
|
|
return activeCards
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateContent() {
|
|
let oldContent = bannerContent
|
|
let newContent = fetchContent()
|
|
|
|
// The data source is only set after -viewDidLoad
|
|
// We can skip animations if we haven't been showing content to begin with
|
|
let isAnimated = (collectionView.dataSource === self)
|
|
|
|
if isAnimated {
|
|
collectionView.performBatchUpdates {
|
|
bannerContent = newContent
|
|
|
|
let oldBannerIds = oldContent.map { $0.identifier }
|
|
let newBannerIds = newContent.map { $0.identifier }
|
|
|
|
// Delete everything in oldBannerIds that's not in newBannerIds
|
|
collectionView.deleteItems(
|
|
at: oldBannerIds.enumerated()
|
|
.filter { newBannerIds.contains($0.element) == false }
|
|
.map { IndexPath(item: $0.offset, section: 0) })
|
|
|
|
// Insert everything in newBannerIds that's not in oldBannerIds
|
|
collectionView.insertItems(
|
|
at: newBannerIds.enumerated()
|
|
.filter { oldBannerIds.contains($0.element) == false }
|
|
.map { IndexPath(item: $0.offset, section: 0) })
|
|
}
|
|
} else {
|
|
bannerContent = newContent
|
|
}
|
|
|
|
if bannerContent.count == 0 {
|
|
delegate?.getStartedBannerDidDismissAllCards(self, animated: isAnimated)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - UICollectionViewDataSource
|
|
|
|
extension GetStartedBannerViewController: UICollectionViewDataSource {
|
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
|
if section == 0 {
|
|
return bannerContent.count
|
|
} else {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
|
guard indexPath.item < bannerContent.count,
|
|
let cell = collectionView.dequeueReusableCell(
|
|
withReuseIdentifier: GetStartedBannerCell.reuseIdentifier,
|
|
for: indexPath) as? GetStartedBannerCell else {
|
|
owsFailDebug("Unrecognized cell type")
|
|
return UICollectionViewCell()
|
|
}
|
|
|
|
let model = bannerContent[indexPath.item]
|
|
cell.configure(model: model, delegate: self)
|
|
return cell
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
extension GetStartedBannerViewController: GetStartedBannerCellDelegate {
|
|
func didTapClose(_ cell: GetStartedBannerCell) {
|
|
guard let model = cell.model else { return }
|
|
|
|
SDSDatabaseStorage.shared.asyncWrite { writeTx in
|
|
Self.completeCard(model, writeTx: writeTx)
|
|
}
|
|
}
|
|
|
|
func didTapAction(_ cell: GetStartedBannerCell) {
|
|
guard let model = cell.model else { return }
|
|
|
|
switch model {
|
|
case .inviteFriends:
|
|
delegate?.getStartedBannerDidTapInviteFriends(self)
|
|
case .newGroup:
|
|
delegate?.getStartedBannerDidTapCreateGroup(self)
|
|
case .appearance:
|
|
delegate?.getStartedBannerDidTapAppearance(self)
|
|
case .avatarBuilder:
|
|
delegate?.getStartedBannerDidTapAvatarBuilder(self)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Storage
|
|
|
|
extension GetStartedBannerViewController {
|
|
private static let activeCardsDidChange = NSNotification.Name("ActiveBannerCardsDidChange")
|
|
private static let keyValueStore = SDSKeyValueStore(collection: "GetStartedBannerViewController")
|
|
private static let completePrefix = "ActiveCard."
|
|
|
|
@objc(enableAllCardsWithTransaction:)
|
|
static func enableAllCards(writeTx: SDSAnyWriteTransaction) {
|
|
var didChange = false
|
|
|
|
GetStartedBannerEntry.allCases.forEach { entry in
|
|
let key = completePrefix + entry.identifier
|
|
|
|
let isActive = keyValueStore.getBool(key, defaultValue: false, transaction: writeTx)
|
|
guard !isActive else {
|
|
// Card already active.
|
|
return
|
|
}
|
|
|
|
Self.keyValueStore.setBool(true, key: key, transaction: writeTx)
|
|
didChange = true
|
|
}
|
|
|
|
guard didChange else {
|
|
return
|
|
}
|
|
|
|
writeTx.addSyncCompletion {
|
|
NotificationCenter.default.postNotificationNameAsync(activeCardsDidChange, object: nil)
|
|
}
|
|
}
|
|
|
|
static private func getActiveCards(readTx: SDSAnyReadTransaction) -> [GetStartedBannerEntry] {
|
|
GetStartedBannerEntry.allCases.filter { entry in
|
|
let key = completePrefix + entry.identifier
|
|
let isActive = keyValueStore.getBool(key, defaultValue: false, transaction: readTx)
|
|
return isActive
|
|
}
|
|
}
|
|
|
|
static func dismissAllCards(writeTx: SDSAnyWriteTransaction) {
|
|
var didChange = false
|
|
|
|
GetStartedBannerEntry.allCases.forEach { entry in
|
|
let key = completePrefix + entry.identifier
|
|
|
|
let isActive = keyValueStore.getBool(key, defaultValue: false, transaction: writeTx)
|
|
guard isActive else {
|
|
// Card not active.
|
|
return
|
|
}
|
|
|
|
Self.keyValueStore.removeValue(forKey: key, transaction: writeTx)
|
|
didChange = true
|
|
}
|
|
|
|
guard didChange else {
|
|
return
|
|
}
|
|
|
|
writeTx.addSyncCompletion {
|
|
NotificationCenter.default.postNotificationNameAsync(activeCardsDidChange, object: nil)
|
|
}
|
|
}
|
|
|
|
static private func completeCard(_ model: GetStartedBannerEntry, writeTx: SDSAnyWriteTransaction) {
|
|
let key = Self.completePrefix + model.identifier
|
|
|
|
let isActive = keyValueStore.getBool(key, defaultValue: false, transaction: writeTx)
|
|
guard isActive else {
|
|
// Card not active.
|
|
return
|
|
}
|
|
|
|
Self.keyValueStore.removeValue(forKey: key, transaction: writeTx)
|
|
|
|
writeTx.addSyncCompletion {
|
|
NotificationCenter.default.postNotificationNameAsync(activeCardsDidChange, object: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Database Observation
|
|
|
|
extension GetStartedBannerViewController: DatabaseChangeDelegate {
|
|
public func databaseChangesWillUpdate() {}
|
|
|
|
public func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {
|
|
AssertIsOnMainThread()
|
|
owsAssertDebug(AppReadiness.isAppReady)
|
|
if databaseChanges.didUpdateThreads {
|
|
updateContent()
|
|
}
|
|
}
|
|
|
|
public func databaseChangesDidUpdateExternally() {
|
|
AssertIsOnMainThread()
|
|
owsAssertDebug(AppReadiness.isAppReady)
|
|
updateContent()
|
|
}
|
|
|
|
public func databaseChangesDidReset() {
|
|
AssertIsOnMainThread()
|
|
owsAssertDebug(AppReadiness.isAppReady)
|
|
updateContent()
|
|
}
|
|
|
|
@objc
|
|
private func activeCardsDidChange() {
|
|
AssertIsOnMainThread()
|
|
owsAssertDebug(AppReadiness.isAppReady)
|
|
updateContent()
|
|
}
|
|
|
|
@objc
|
|
private func localProfileDidChange() {
|
|
AssertIsOnMainThread()
|
|
owsAssertDebug(AppReadiness.isAppReady)
|
|
updateContent()
|
|
}
|
|
}
|
|
|
|
// Wrapper view for a collection of interactable subviews
|
|
// Will not return a positive hit test result for itself
|
|
class PassthroughView: UIView {
|
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
let result = super.hitTest(point, with: event)
|
|
return result != self ? result : nil
|
|
}
|
|
}
|