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

public import SignalServiceKit
public import SignalUI

private class StickerPackActionButton: UIView {

    private let block: () -> Void

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

    init(actionIconName: String, block: @escaping () -> Void) {
        self.block = block

        super.init(frame: .zero)

        configure(actionIconName: actionIconName)
    }

    private func configure(actionIconName: String) {
        let actionIconSize: CGFloat = 20
        let actionCircleSize: CGFloat = 32
        let actionCircleView = CircleView(diameter: actionCircleSize)
        actionCircleView.backgroundColor = Theme.washColor
        let actionIcon = UIImage(named: actionIconName)?.withRenderingMode(.alwaysTemplate)
        let actionIconView = UIImageView(image: actionIcon)
        actionIconView.tintColor = Theme.secondaryTextAndIconColor
        actionCircleView.addSubview(actionIconView)
        actionIconView.autoCenterInSuperview()
        actionIconView.autoSetDimensions(to: CGSize(square: actionIconSize))

        self.addSubview(actionCircleView)
        actionCircleView.autoPinEdgesToSuperviewEdges()

        isUserInteractionEnabled = true
        addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapButton)))
    }

    @objc
    func didTapButton(sender: UIGestureRecognizer) {
        block()
    }
}

// MARK: -

public class ManageStickersViewController: OWSTableViewController2 {

    typealias DatedStickerPackInfo = StickerManager.DatedStickerPackInfo

    // MARK: - View Lifecycle

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

        navigationItem.title = OWSLocalizedString("STICKERS_MANAGE_VIEW_TITLE", comment: "Title for the 'manage stickers' view.")

        navigationItem.leftBarButtonItem = .cancelButton(dismissingFrom: self)
    }

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

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(packsDidChange),
            name: StickerManager.packsDidChange,
            object: nil,
        )

        defaultSeparatorInsetLeading = Self.cellHInnerMargin + iconSize + iconSpacing

        updateState()

        let stickerManager = SSKEnvironment.shared.stickerManagerRef
        stickerManager.downloadPendingSickerPacks()
    }

    private var pendingModalVC: ModalActivityIndicatorViewController?

    private var needsStateUpdate = false {
        didSet {
            if needsStateUpdate {
                updateEvent.requestNotify()
            }
        }
    }

    private var needsTableUpdate = false {
        didSet {
            if needsTableUpdate {
                updateEvent.requestNotify()
            }
        }
    }

    private lazy var updateEvent: DebouncedEvent = {
        DebouncedEvents.build(
            mode: .firstLast,
            maxFrequencySeconds: 0.75,
            onQueue: .main,
            notifyBlock: { [weak self] in
                guard let self else { return }
                if self.needsStateUpdate {
                    self.updateState()
                } else if self.needsTableUpdate {
                    self.buildTable()
                }
            },
        )
    }()

    private var installedStickerPackSources = [StickerPackDataSource]()
    private var availableBuiltInStickerPackSources = [StickerPackDataSource]()
    private var knownStickerPackSources = [StickerPackDataSource]()

    private func updateState() {
        // If we're presenting a modal because the user tapped install, dismiss it.
        pendingModalVC?.dismiss()
        pendingModalVC = nil

        // We need to recycle data sources to maintain continuity.
        var oldInstalledSources = [StickerPackInfo: StickerPackDataSource]()
        var oldTransientSources = [StickerPackInfo: StickerPackDataSource]()
        let updateMapWithOldSources = { (map: inout [StickerPackInfo: StickerPackDataSource], sources: [StickerPackDataSource]) in
            for source in sources {
                guard let info = source.info else {
                    owsFailDebug("Source missing info.")
                    continue
                }
                map[info] = source
            }
        }
        updateMapWithOldSources(&oldInstalledSources, installedStickerPackSources)
        updateMapWithOldSources(&oldTransientSources, availableBuiltInStickerPackSources)
        updateMapWithOldSources(&oldTransientSources, knownStickerPackSources)

        var installedStickerPacks = [StickerPackRecord]()
        var availableBuiltInStickerPacks = [StickerPackRecord]()
        var availableKnownStickerPacksFromMessages = [DatedStickerPackInfo]()
        SSKEnvironment.shared.databaseStorageRef.read { transaction in
            let allPacks = StickerManager.allStickerPacks(transaction: transaction)
            let allPackInfos = allPacks.map { $0.info }

            // Only show packs with installed covers.
            let packsWithCovers = allPacks.filter {
                StickerManager.isStickerInstalled(stickerInfo: $0.coverInfo, transaction: transaction)
            }
            // Sort sticker packs by "date saved, descending" so that we feature
            // packs that the user has just learned about.
            installedStickerPacks = packsWithCovers.filter { $0.isInstalled }
            availableBuiltInStickerPacks = packsWithCovers.filter {
                !$0.isInstalled && StickerManager.isDefaultStickerPack(packId: $0.info.packId)
            }
            let knownStickerPackFromMessages = StickerManager.knownStickerPacksFromMessages(transaction: transaction)
            availableKnownStickerPacksFromMessages = knownStickerPackFromMessages.filter { !allPackInfos.contains($0.info) }
        }

        let installedSource = { (info: StickerPackInfo) -> StickerPackDataSource in
            if let source = oldInstalledSources[info] {
                return source
            }
            let source = InstalledStickerPackDataSource(stickerPackInfo: info)
            source.add(delegate: self)
            return source
        }
        let transientSource = { (info: StickerPackInfo) -> StickerPackDataSource in
            if let source = oldTransientSources[info] {
                return source
            }
            // Don't download all stickers; we only need covers for this view.
            let source = TransientStickerPackDataSource(
                stickerPackInfo: info,
                shouldDownloadAllStickers: false,
            )
            source.add(delegate: self)
            return source
        }

        self.installedStickerPackSources = installedStickerPacks.sorted {
            $0.dateCreated > $1.dateCreated
        }.map {
            installedSource($0.info)
        }
        self.availableBuiltInStickerPackSources = availableBuiltInStickerPacks.sorted {
            $0.dateCreated > $1.dateCreated
        }.map {
            transientSource($0.info)
        }
        self.knownStickerPackSources = availableKnownStickerPacksFromMessages.sorted {
            $0.timestamp > $1.timestamp
        }.map {
            transientSource($0.info)
        }

        needsStateUpdate = false
        buildTable()
    }

    private func buildTable() {
        let contents = OWSTableContents()

        let installedSection = OWSTableSection()
        installedSection.headerTitle = OWSLocalizedString("STICKERS_MANAGE_VIEW_INSTALLED_PACKS_SECTION_TITLE", comment: "Title for the 'installed stickers' section of the 'manage stickers' view.")
        if installedStickerPackSources.isEmpty {
            let text = OWSLocalizedString("STICKERS_MANAGE_VIEW_NO_INSTALLED_PACKS", comment: "Label indicating that the user has no installed sticker packs.")
            installedSection.add(buildEmptySectionItem(labelText: text))
        }
        for dataSource in installedStickerPackSources {
            installedSection.add(OWSTableItem(
                customCellBlock: { [weak self] in
                    guard let self else {
                        return UITableViewCell()
                    }
                    return self.buildTableCell(installedStickerPack: dataSource)
                },
                actionBlock: { [weak self] in
                    guard let packInfo = dataSource.info else {
                        owsFailDebug("Source missing info.")
                        return
                    }
                    self?.show(packInfo: packInfo)
                },
            ))
        }
        contents.add(installedSection)

        let itemForAvailablePack = { (dataSource: StickerPackDataSource) -> OWSTableItem in
            OWSTableItem(
                customCellBlock: { [weak self] in
                    guard let self else {
                        return UITableViewCell()
                    }
                    return self.buildTableCell(availableStickerPack: dataSource)
                },
                actionBlock: { [weak self] in
                    guard let packInfo = dataSource.info else {
                        owsFailDebug("Source missing info.")
                        return
                    }
                    self?.show(packInfo: packInfo)
                },
            )
        }
        if !availableBuiltInStickerPackSources.isEmpty {
            let section = OWSTableSection()
            section.headerTitle = OWSLocalizedString("STICKERS_MANAGE_VIEW_AVAILABLE_BUILT_IN_PACKS_SECTION_TITLE", comment: "Title for the 'available built-in stickers' section of the 'manage stickers' view.")
            for dataSource in availableBuiltInStickerPackSources {
                section.add(itemForAvailablePack(dataSource))
            }
            contents.add(section)
        }

        // Sticker packs whose manifest is available.
        var loadedKnownStickerPackSources = [StickerPackDataSource]()
        // Sticker packs whose manifest is downloading.
        var loadingKnownStickerPackSources = [StickerPackDataSource]()
        // Sticker packs whose manifest download failed permanently.
        var failedKnownStickerPackSources = [StickerPackDataSource]()
        for source in knownStickerPackSources {
            guard source.getStickerPack() == nil else {
                // Already loaded.
                loadedKnownStickerPackSources.append(source)
                continue
            }
            guard let info = source.info else {
                owsFailDebug("Known source missing info.")
                continue
            }
            // Hide sticker packs whose download failed permanently.
            let isFailed = StickerManager.isStickerPackMissing(stickerPackInfo: info)
            if isFailed {
                failedKnownStickerPackSources.append(source)
            } else {
                loadingKnownStickerPackSources.append(source)
            }
        }
        let knownSection = OWSTableSection()
        knownSection.headerTitle = OWSLocalizedString("STICKERS_MANAGE_VIEW_AVAILABLE_KNOWN_PACKS_SECTION_TITLE", comment: "Title for the 'available known stickers' section of the 'manage stickers' view.")
        if knownStickerPackSources.isEmpty {
            let text = OWSLocalizedString("STICKERS_MANAGE_VIEW_NO_KNOWN_PACKS", comment: "Label indicating that the user has no known sticker packs.")
            knownSection.add(buildEmptySectionItem(labelText: text))
        }
        for dataSource in loadedKnownStickerPackSources {
            knownSection.add(itemForAvailablePack(dataSource))
        }
        if !loadingKnownStickerPackSources.isEmpty {
            let text = OWSLocalizedString(
                "STICKERS_MANAGE_VIEW_LOADING_KNOWN_PACKS",
                comment: "Label indicating that one or more known sticker packs is loading.",
            )
            knownSection.add(buildEmptySectionItem(labelText: text))
        } else if !failedKnownStickerPackSources.isEmpty {
            let text = OWSLocalizedString(
                "STICKERS_MANAGE_VIEW_FAILED_KNOWN_PACKS",
                comment: "Label indicating that one or more known sticker packs failed to load.",
            )
            knownSection.add(buildEmptySectionItem(labelText: text))
        }
        contents.add(knownSection)

        self.contents = contents
        needsTableUpdate = false
    }

    private func buildTableCell(installedStickerPack dataSource: StickerPackDataSource) -> UITableViewCell {
        return buildTableCell(dataSource: dataSource, actionIconName: "reply-fill") { [weak self] in
            guard let packInfo = dataSource.info else {
                owsFailDebug("Source missing info.")
                return
            }
            self?.share(packInfo: packInfo)
        }
    }

    private func buildTableCell(availableStickerPack dataSource: StickerPackDataSource) -> UITableViewCell {
        if let stickerPack = dataSource.getStickerPack() {
            let actionIconName = Theme.iconName(.buttonSave)
            return buildTableCell(dataSource: dataSource, actionIconName: actionIconName) { [weak self] in
                self?.install(stickerPack: stickerPack)
            }
        } else {
            // Hide "install" button if manifest isn't downloaded yet.
            return buildTableCell(dataSource: dataSource, actionIconName: nil) { }
        }
    }

    private typealias StickerViewCache = LRUCache<Data, ThreadSafeCacheHandle<StickerReusableView>>
    private let reusableCoverViewCache = StickerViewCache(maxSize: 16, shouldEvacuateInBackground: true)
    private func reusableCoverView(forDataSource dataSource: StickerPackDataSource) -> StickerReusableView? {
        guard let packId = dataSource.info?.packId else { return nil }

        let view: StickerReusableView = {
            if let view = reusableCoverViewCache.object(forKey: packId)?.value {
                return view
            }
            let view = StickerReusableView()
            reusableCoverViewCache.setObject(ThreadSafeCacheHandle(view), forKey: packId)
            return view
        }()

        guard !view.hasStickerView else { return view }

        guard
            let stickerInfo = dataSource.installedCoverInfo,
            let imageView = imageView(forStickerInfo: stickerInfo, dataSource: dataSource)
        else {
            view.showPlaceholder()
            return view
        }

        view.configure(with: imageView)

        return view
    }

    private let iconSize: CGFloat = 56
    private let iconSpacing: CGFloat = 12
    private func buildTableCell(
        dataSource: StickerPackDataSource,
        actionIconName: String?,
        block: @escaping () -> Void,
    ) -> UITableViewCell {

        let cell = OWSTableItem.newCell()

        guard let packInfo = dataSource.info else {
            owsFailDebug("Source missing info.")
            return cell
        }

        let titleValue = dataSource.title?.filterForDisplay
        let authorNameValue = dataSource.author?.filterForDisplay

        let iconView = reusableCoverView(forDataSource: dataSource) ?? UIView()
        iconView.autoSetDimensions(to: CGSize(square: iconSize))

        let title: String
        if let titleValue = titleValue?.ows_stripped(), !titleValue.isEmpty {
            title = titleValue
        } else {
            title = OWSLocalizedString("STICKERS_PACK_DEFAULT_TITLE", comment: "Default title for sticker packs.")
        }
        let titleLabel = UILabel()
        titleLabel.text = title
        titleLabel.font = UIFont.dynamicTypeHeadline
        titleLabel.textColor = Theme.primaryTextColor
        titleLabel.lineBreakMode = .byTruncatingTail

        let textStack = UIStackView(arrangedSubviews: [
            titleLabel,
        ])
        textStack.axis = .vertical
        textStack.alignment = .leading
        textStack.spacing = 4
        textStack.setContentHuggingHorizontalLow()
        textStack.setCompressionResistanceHorizontalLow()

        // TODO: Should we show a default author name?

        let isDefaultStickerPack = StickerManager.isDefaultStickerPack(packId: packInfo.packId)

        var authorViews = [UIView]()
        if isDefaultStickerPack {
            let builtInPackView = UIImageView()
            builtInPackView.setTemplateImageName("check-circle-fill-compact", tintColor: Theme.accentBlueColor)
            builtInPackView.setCompressionResistanceHigh()
            builtInPackView.setContentHuggingHigh()
            authorViews.append(builtInPackView)
        }

        if let authorName = authorNameValue?.ows_stripped(), !authorName.isEmpty {
            let authorLabel = UILabel()
            authorLabel.text = authorName
            authorLabel.font = isDefaultStickerPack ? UIFont.dynamicTypeCaption1.semibold() : UIFont.dynamicTypeCaption1
            authorLabel.textColor = isDefaultStickerPack ? Theme.accentBlueColor : Theme.secondaryTextAndIconColor
            authorLabel.lineBreakMode = .byTruncatingTail
            authorViews.append(authorLabel)
        }

        if authorViews.count > 0 {
            let authorStack = UIStackView(arrangedSubviews: authorViews)
            authorStack.axis = .horizontal
            authorStack.alignment = .center
            authorStack.spacing = 4
            textStack.addArrangedSubview(authorStack)
        }

        var subviews: [UIView] = [
            iconView,
            textStack,
        ]

        if let actionIconName {
            let actionButton = StickerPackActionButton(actionIconName: actionIconName, block: block)
            subviews.append(actionButton)
        }

        let stack = UIStackView(arrangedSubviews: subviews)
        stack.axis = .horizontal
        stack.alignment = .center
        stack.spacing = iconSpacing

        cell.contentView.addSubview(stack)
        stack.autoPinEdgesToSuperviewMargins()

        return cell
    }

    private func imageView(
        forStickerInfo stickerInfo: StickerInfo,
        dataSource: StickerPackDataSource,
    ) -> UIView? {
        StickerView.stickerView(forStickerInfo: stickerInfo, dataSource: dataSource)
    }

    private func buildEmptySectionItem(labelText: String) -> OWSTableItem {
        return OWSTableItem(customCellBlock: { [weak self] in
            guard let self else {
                return UITableViewCell()
            }
            return self.buildEmptySectionCell(labelText: labelText)
        })
    }

    private func buildEmptySectionCell(labelText: String) -> UITableViewCell {
        let cell = OWSTableItem.newCell()

        let label = UILabel()
        label.text = labelText
        label.font = UIFont.dynamicTypeCaption1
        label.textColor = Theme.secondaryTextAndIconColor
        label.textAlignment = .center
        label.numberOfLines = 0
        label.lineBreakMode = .byWordWrapping
        label.setCompressionResistanceHigh()
        label.setContentHuggingHigh()
        cell.contentView.addSubview(label)
        label.autoPinEdgesToSuperviewMargins()

        return cell
    }

    // MARK: Events

    private func show(packInfo: StickerPackInfo) {
        AssertIsOnMainThread()

        let packView = StickerPackViewController(stickerPackInfo: packInfo)
        packView.present(from: self, animated: true)
    }

    // We need to retain a link to the send flow during the send flow.
    private var sendMessageFlow: SendMessageFlow?

    private func share(packInfo: StickerPackInfo) {
        AssertIsOnMainThread()

        let packUrl = packInfo.shareUrl()

        guard let navigationController = self.navigationController else {
            owsFailDebug("Missing navigationController.")
            return
        }
        let messageBody = MessageBody(text: packUrl, ranges: .empty)
        guard let unapprovedContent = SendMessageUnapprovedContent(messageBody: messageBody) else {
            owsFailDebug("Missing messageBody.")
            return
        }
        let sendMessageFlow = SendMessageFlow(
            unapprovedContent: unapprovedContent,
            presentationStyle: .pushOnto(navigationController),
            delegate: self,
        )
        // Retain the flow until it is complete.
        self.sendMessageFlow = sendMessageFlow
    }

    private func install(stickerPack: StickerPackRecord) {
        AssertIsOnMainThread()

        let modalVC = ModalActivityIndicatorViewController(canCancel: false, presentationDelay: 0)
        modalVC.modalPresentationStyle = .overFullScreen
        present(modalVC, animated: false, completion: nil)

        // This will be dismissed once we receive a sticker pack update notification from StickerManager
        pendingModalVC = modalVC
        SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in
            StickerManager.installStickerPack(
                stickerPack: stickerPack,
                wasLocallyInitiated: true,
                transaction: transaction,
            )
        }

        // or... if 6s have passed. just to be safe.
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(6)) { [weak self] in
            // If the current modal isn't the one we created, we can ignore it
            guard modalVC == self?.pendingModalVC else { return }

            if self != nil, SSKEnvironment.shared.reachabilityManagerRef.isReachable {
                owsFailDebug("Expected to hear back from StickerManager about a newly installed sticker pack")
            }
            self?.updateState()
        }
    }

    @objc
    func packsDidChange() {
        AssertIsOnMainThread()

        needsStateUpdate = true
    }
}

// MARK: -

extension ManageStickersViewController: StickerPackDataSourceDelegate {
    public func stickerPackDataDidChange() {
        AssertIsOnMainThread()
        needsTableUpdate = true
    }
}

// MARK: -

extension ManageStickersViewController: SendMessageDelegate {

    public func sendMessageFlowDidComplete(threads: [TSThread]) {
        AssertIsOnMainThread()

        sendMessageFlow = nil

        navigationController?.popToViewController(self, animated: true)
    }

    public func sendMessageFlowWillShowConversation() {
        AssertIsOnMainThread()

        sendMessageFlow = nil

        // Don't pop anything -- the flow does that itself.
    }

    public func sendMessageFlowDidCancel() {
        AssertIsOnMainThread()

        sendMessageFlow = nil

        navigationController?.popToViewController(self, animated: true)
    }
}