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