Path: blob/main/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift
1 views
//
// Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import LibSignalClient
import SignalServiceKit
import SignalUI
class GifPickerNavigationViewController: OWSNavigationController {
weak var approvalDelegate: AttachmentApprovalViewControllerDelegate?
weak var approvalDataSource: AttachmentApprovalViewControllerDataSource?
private var initialMessageBody: MessageBody?
private let hasQuotedReplyDraft: Bool
lazy var gifPickerViewController: GifPickerViewController = {
let gifPickerViewController = GifPickerViewController()
gifPickerViewController.delegate = self
return gifPickerViewController
}()
init(initialMessageBody: MessageBody?, hasQuotedReplyDraft: Bool) {
self.initialMessageBody = initialMessageBody
self.hasQuotedReplyDraft = hasQuotedReplyDraft
super.init()
pushViewController(gifPickerViewController, animated: false)
}
}
extension GifPickerNavigationViewController: GifPickerViewControllerDelegate {
func gifPickerDidSelect(attachment: PreviewableAttachment, attachmentLimits: OutgoingAttachmentLimits) {
AssertIsOnMainThread()
let attachmentApprovalItem = AttachmentApprovalItem(attachment: attachment, canSave: false)
let attachmentApproval = AttachmentApprovalViewController.loadWithSneakyTransaction(
attachmentApprovalItems: [attachmentApprovalItem],
attachmentLimits: attachmentLimits,
options: self.hasQuotedReplyDraft ? [.disallowViewOnce] : [],
)
attachmentApproval.approvalDataSource = self
attachmentApproval.setMessageBody(initialMessageBody, txProvider: DependenciesBridge.shared.db.readTxProvider)
attachmentApproval.approvalDelegate = self
pushViewController(attachmentApproval, animated: true) {
// Remove any selected state in case the user returns "back" to the gif picker.
self.gifPickerViewController.clearSelectedState()
}
}
func gifPickerDidCancel() {
approvalDelegate?.attachmentApprovalDidCancel()
}
}
extension GifPickerNavigationViewController: AttachmentApprovalViewControllerDelegate {
func attachmentApproval(
_ attachmentApproval: AttachmentApprovalViewController,
didApproveAttachments approvedAttachments: ApprovedAttachments,
messageBody: MessageBody?,
) {
approvalDelegate?.attachmentApproval(
attachmentApproval,
didApproveAttachments: approvedAttachments,
messageBody: messageBody,
)
}
func attachmentApprovalDidCancel() {
approvalDelegate?.attachmentApprovalDidCancel()
}
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageBody newMessageBody: MessageBody?) {
approvalDelegate?.attachmentApproval(attachmentApproval, didChangeMessageBody: newMessageBody)
}
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachmentApprovalItem: AttachmentApprovalItem) { }
func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) { }
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeViewOnceState isViewOnce: Bool) { }
}
extension GifPickerNavigationViewController: AttachmentApprovalViewControllerDataSource {
var attachmentApprovalTextInputContextIdentifier: String? {
return approvalDataSource?.attachmentApprovalTextInputContextIdentifier
}
var attachmentApprovalRecipientNames: [String] {
approvalDataSource?.attachmentApprovalRecipientNames ?? []
}
func attachmentApprovalMentionableAcis(tx: DBReadTransaction) -> [Aci] {
return approvalDataSource?.attachmentApprovalMentionableAcis(tx: tx) ?? []
}
func attachmentApprovalMentionCacheInvalidationKey() -> String {
return approvalDataSource?.attachmentApprovalMentionCacheInvalidationKey() ?? UUID().uuidString
}
}
protocol GifPickerViewControllerDelegate: AnyObject {
@MainActor
func gifPickerDidSelect(attachment: PreviewableAttachment, attachmentLimits: OutgoingAttachmentLimits)
@MainActor
func gifPickerDidCancel()
}
class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, GifPickerLayoutDelegate, OWSNavigationChildController {
// MARK: Properties
enum ViewMode {
case idle
case searching
case results
case noResults
case error
}
private var viewMode = ViewMode.idle {
didSet {
updateContents()
}
}
weak var delegate: GifPickerViewControllerDelegate?
let searchBar: UISearchBar
let layout: GifPickerLayout
let collectionView: UICollectionView
var noResultsView: UILabel?
var searchErrorView: UILabel?
var activityIndicator: UIActivityIndicatorView?
var hasSelectedCell: Bool = false
var imageInfos = [GiphyImageInfo]() {
didSet {
collectionView.collectionViewLayout.invalidateLayout()
collectionView.reloadData()
}
}
private let kCellReuseIdentifier = "kCellReuseIdentifier"
private let taskQueue = SerialTaskQueue()
// MARK: Initializers
override init() {
self.searchBar = UISearchBar()
self.layout = GifPickerLayout()
self.collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: self.layout)
super.init()
self.layout.delegate = self
}
// MARK: -
@objc
private func didBecomeActive() {
AssertIsOnMainThread()
// Prod cells to try to load when app becomes active.
ensureCellState()
}
@objc
private func reachabilityChanged() {
AssertIsOnMainThread()
// Prod cells to try to load when connectivity changes.
ensureCellState()
}
func ensureCellState() {
for cell in self.collectionView.visibleCells {
guard let cell = cell as? GifPickerCell else {
owsFailDebug("unexpected cell.")
return
}
cell.ensureCellState()
}
}
// MARK: View Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
self.navigationItem.leftBarButtonItem = .cancelButton { [weak self] in
self?.delegate?.gifPickerDidCancel()
}
self.navigationItem.title = OWSLocalizedString(
"GIF_PICKER_VIEW_TITLE",
comment: "Title for the 'GIF picker' dialog.",
)
createViews()
NotificationCenter.default.addObserver(
self,
selector: #selector(reachabilityChanged),
name: SSKReachability.owsReachabilityDidChange,
object: nil,
)
NotificationCenter.default.addObserver(
self,
selector: #selector(didBecomeActive),
name: .OWSApplicationDidBecomeActive,
object: nil,
)
taskQueue.enqueueCancellingPrevious(operation: { @MainActor in
await self.tryToSearch(afterDelay: 0)
})
}
var hasEverAppeared = false
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !hasEverAppeared {
searchBar.becomeFirstResponder()
}
hasEverAppeared = true
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
taskQueue.cancelAll()
fileForCellTask?.cancel()
fileForCellTask = nil
}
var preferredNavigationBarStyle: OWSNavigationBarStyle { .solid }
var navbarBackgroundColorOverride: UIColor? { view.backgroundColor }
override func themeDidChange() {
super.themeDidChange()
view.backgroundColor = Theme.backgroundColor
owsNavigationController?.updateNavbarAppearance()
}
// MARK: Views
func clearSelectedState() {
hasSelectedCell = false
collectionView.isUserInteractionEnabled = true
selectedMaskingView.isHidden = true
if let selectedIndices = collectionView.indexPathsForSelectedItems {
for index in selectedIndices {
collectionView.deselectItem(at: index, animated: false)
if let cell = collectionView.cellForItem(at: index) {
cell.isSelected = false
}
}
}
}
let selectedMaskingView = BezierPathView()
private func createViews() {
let backgroundColor = (
Theme.isDarkThemeEnabled
? UIColor(white: 0.08, alpha: 1.0)
: Theme.backgroundColor,
)
self.view.backgroundColor = backgroundColor
self.collectionView.delegate = self
self.collectionView.dataSource = self
self.collectionView.backgroundColor = backgroundColor
self.collectionView.contentInsetAdjustmentBehavior = .never
self.collectionView.register(GifPickerCell.self, forCellWithReuseIdentifier: kCellReuseIdentifier)
view.addSubview(self.collectionView)
self.collectionView.autoPinEdge(toSuperviewSafeArea: .leading)
self.collectionView.autoPinEdge(toSuperviewSafeArea: .trailing)
view.addSubview(selectedMaskingView)
selectedMaskingView.autoPinEdge(.top, to: .top, of: collectionView)
selectedMaskingView.autoPinEdge(.leading, to: .leading, of: collectionView)
selectedMaskingView.autoPinEdge(.trailing, to: .trailing, of: collectionView)
selectedMaskingView.autoPinEdge(.bottom, to: .bottom, of: collectionView)
selectedMaskingView.isHidden = true
// Search
searchBar.delegate = self
searchBar.placeholder = OWSLocalizedString(
"GIF_VIEW_SEARCH_PLACEHOLDER_TEXT",
comment: "Placeholder text for the search field in GIF view",
)
view.addSubview(searchBar)
searchBar.autoPinWidthToSuperview()
searchBar.autoPin(toTopLayoutGuideOf: self, withInset: 0)
searchBar.autoPinEdge(.bottom, to: .top, of: collectionView)
// for iPhoneX devices, extends the black background to the bottom edge of the view.
let bottomBannerContainer = UIView()
bottomBannerContainer.backgroundColor = UIColor.black
self.view.addSubview(bottomBannerContainer)
bottomBannerContainer.autoPinWidthToSuperview()
bottomBannerContainer.autoPinEdge(.top, to: .bottom, of: self.collectionView)
bottomBannerContainer.autoPinEdge(toSuperviewEdge: .bottom)
let bottomBanner = UIView()
bottomBannerContainer.addSubview(bottomBanner)
bottomBanner.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
bottomBanner.topAnchor.constraint(equalTo: bottomBannerContainer.topAnchor),
bottomBanner.leadingAnchor.constraint(equalTo: bottomBannerContainer.leadingAnchor),
bottomBanner.trailingAnchor.constraint(equalTo: bottomBannerContainer.trailingAnchor),
bottomBanner.bottomAnchor.constraint(equalTo: keyboardLayoutGuide.topAnchor),
])
// The Giphy API requires us to "show their trademark prominently" in our GIF experience.
let logoImage = UIImage(named: "giphy_logo")
let logoImageView = UIImageView(image: logoImage)
bottomBanner.addSubview(logoImageView)
logoImageView.autoPinHeightToSuperview(withMargin: 3)
logoImageView.autoHCenterInSuperview()
let noResultsView = createErrorLabel(text: OWSLocalizedString(
"GIF_VIEW_SEARCH_NO_RESULTS",
comment: "Indicates that the user's search had no results.",
))
self.noResultsView = noResultsView
self.view.addSubview(noResultsView)
noResultsView.autoPinWidthToSuperview(withMargin: 20)
noResultsView.autoAlignAxis(.horizontal, toSameAxisOf: self.collectionView)
let searchErrorView = createErrorLabel(text: OWSLocalizedString(
"GIF_VIEW_SEARCH_ERROR",
comment: "Indicates that an error occurred while searching.",
))
self.searchErrorView = searchErrorView
self.view.addSubview(searchErrorView)
searchErrorView.autoPinWidthToSuperview(withMargin: 20)
searchErrorView.autoAlignAxis(.horizontal, toSameAxisOf: self.collectionView)
searchErrorView.isUserInteractionEnabled = true
searchErrorView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(retryTapped)))
let activityIndicator = UIActivityIndicatorView(style: .medium)
self.activityIndicator = activityIndicator
self.view.addSubview(activityIndicator)
activityIndicator.autoHCenterInSuperview()
activityIndicator.autoAlignAxis(.horizontal, toSameAxisOf: self.collectionView)
self.updateContents()
}
private func createErrorLabel(text: String) -> UILabel {
let label = UILabel()
label.text = text
label.textColor = Theme.primaryTextColor
label.font = UIFont.semiboldFont(ofSize: 20)
label.textAlignment = .center
label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping
return label
}
private func updateContents() {
guard let noResultsView = self.noResultsView else {
owsFailDebug("Missing noResultsView")
return
}
guard let searchErrorView = self.searchErrorView else {
owsFailDebug("Missing searchErrorView")
return
}
guard let activityIndicator = self.activityIndicator else {
owsFailDebug("Missing activityIndicator")
return
}
switch viewMode {
case .idle:
self.collectionView.isHidden = true
noResultsView.isHidden = true
searchErrorView.isHidden = true
activityIndicator.isHidden = true
activityIndicator.stopAnimating()
case .searching:
self.collectionView.isHidden = true
noResultsView.isHidden = true
searchErrorView.isHidden = true
activityIndicator.isHidden = false
activityIndicator.startAnimating()
case .results:
self.collectionView.isHidden = false
noResultsView.isHidden = true
searchErrorView.isHidden = true
activityIndicator.isHidden = true
activityIndicator.stopAnimating()
case .noResults:
self.collectionView.isHidden = true
noResultsView.isHidden = false
searchErrorView.isHidden = true
activityIndicator.isHidden = true
activityIndicator.stopAnimating()
case .error:
self.collectionView.isHidden = true
noResultsView.isHidden = true
searchErrorView.isHidden = false
activityIndicator.isHidden = true
activityIndicator.stopAnimating()
}
}
// MARK: - UIScrollViewDelegate
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
self.searchBar.resignFirstResponder()
}
// MARK: - UICollectionViewDataSource
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return imageInfos.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: kCellReuseIdentifier, for: indexPath)
guard indexPath.row < imageInfos.count else {
Logger.warn("indexPath: \(indexPath.row) out of range for imageInfo count: \(imageInfos.count) ")
return cell
}
let imageInfo = imageInfos[indexPath.row]
guard let gifCell = cell as? GifPickerCell else {
owsFailDebug("Unexpected cell type.")
return cell
}
gifCell.imageInfo = imageInfo
return cell
}
// MARK: - UICollectionViewDelegate
private func selectableCell(at indexPath: IndexPath) -> GifPickerCell? {
guard let cell = collectionView.cellForItem(at: indexPath) as? GifPickerCell else {
owsFailDebug("unexpected cell.")
return nil
}
guard cell.isDisplayingPreview else {
// we don't want to let the user blindly select a gray cell
return nil
}
guard self.hasSelectedCell == false else {
owsFailDebug("Already selected cell")
return nil
}
return cell
}
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
return self.selectableCell(at: indexPath) != nil
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let cell = self.selectableCell(at: indexPath) else {
return
}
self.hasSelectedCell = true
// Fade out all cells except the selected one.
let cellRect = collectionView.convert(cell.frame, to: selectedMaskingView)
selectedMaskingView.shapeLayerConfigurationBlock = { layer, bounds in
let path = UIBezierPath(rect: bounds)
path.append(UIBezierPath(rect: cellRect))
layer.path = path.cgPath
layer.fillRule = .evenOdd
layer.fillColor = UIColor.black.cgColor
layer.opacity = 0.7
}
selectedMaskingView.isHidden = false
cell.isSelected = true
self.collectionView.isUserInteractionEnabled = false
getFileForCell(cell)
}
private var fileForCellTask: Task<Void, Never>?
func getFileForCell(_ cell: GifPickerCell) {
GiphyDownloader.giphyDownloader.cancelAllRequests()
let attachmentLimits = OutgoingAttachmentLimits.currentLimits()
fileForCellTask?.cancel()
fileForCellTask = Task {
do {
let asset = try await cell.requestRenditionForSending()
let attachment = try await buildAttachment(forAsset: asset, attachmentLimits: attachmentLimits)
self.delegate?.gifPickerDidSelect(attachment: attachment, attachmentLimits: attachmentLimits)
} catch {
let alert = ActionSheetController(
title: OWSLocalizedString("GIF_PICKER_FAILURE_ALERT_TITLE", comment: "Shown when selected GIF couldn't be fetched"),
message: error.userErrorDescription,
)
alert.addAction(ActionSheetAction(title: CommonStrings.retryButton, style: .default) { _ in
self.getFileForCell(cell)
})
alert.addAction(ActionSheetAction(title: CommonStrings.dismissButton, style: .cancel) { _ in
self.delegate?.gifPickerDidCancel()
})
self.presentActionSheet(alert)
}
}
}
@concurrent
private nonisolated func buildAttachment(
forAsset asset: ProxiedContentAsset,
attachmentLimits: OutgoingAttachmentLimits,
) async throws -> PreviewableAttachment {
guard let giphyAsset = asset.assetDescription as? GiphyAsset else {
throw OWSAssertionError("Invalid asset description.")
}
let assetFileExtension = giphyAsset.type.extension
let assetFilePath = asset.filePath
let assetTypeIdentifier = giphyAsset.type.utiType
let consumableFilePath = OWSFileSystem.temporaryFilePath(
fileExtension: assetFileExtension,
isAvailableWhileDeviceLocked: false,
)
try FileManager.default.copyItem(atPath: assetFilePath, toPath: consumableFilePath)
let dataSource = DataSourcePath(filePath: consumableFilePath, ownership: .owned)
let attachment = try PreviewableAttachment.buildAttachment(dataSource: dataSource, dataUTI: assetTypeIdentifier, attachmentLimits: attachmentLimits)
attachment.rawValue.isLoopingVideo = attachment.isVideo
return attachment
}
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard let cell = cell as? GifPickerCell else {
owsFailDebug("unexpected cell.")
return
}
// We only want to load the cells which are on-screen.
cell.isCellVisible = true
}
func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard let cell = cell as? GifPickerCell else {
owsFailDebug("unexpected cell.")
return
}
cell.isCellVisible = false
}
// MARK: - UISearchBarDelegate
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
// Clear error messages immediately.
if viewMode == .error || viewMode == .noResults {
viewMode = .idle
}
// Do progressive search after a delay.
let kProgressiveSearchDelay: TimeInterval = 1.0
taskQueue.enqueueCancellingPrevious(operation: { @MainActor in
await self.tryToSearch(afterDelay: kProgressiveSearchDelay)
})
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
self.searchBar.resignFirstResponder()
taskQueue.enqueueCancellingPrevious(operation: { @MainActor in
await self.tryToSearch(afterDelay: 0)
})
}
private func tryToSearch(afterDelay delay: TimeInterval) async {
let query = searchBar.text!.trimmingCharacters(in: .whitespacesAndNewlines)
await loadResults(afterDelay: delay) {
if query.isEmpty {
return try await GiphyAPI.trending()
} else {
return try await GiphyAPI.search(query: query)
}
}
}
private func showLoading() {
self.imageInfos = []
self.viewMode = .searching
}
private func loadResults(afterDelay delay: TimeInterval, loadImageInfos: () async throws -> [GiphyImageInfo]) async {
self.showLoading()
self.collectionView.contentOffset = .zero
do {
if delay > 0 {
try await Task.sleep(nanoseconds: delay.clampedNanoseconds)
}
let imageInfos = try await loadImageInfos()
try Task.checkCancellation()
self.imageInfos = imageInfos
self.viewMode = imageInfos.isEmpty ? .noResults : .results
Logger.info("Finished loading GIFs")
} catch is CancellationError, URLError.cancelled {
// Do nothing.
} catch {
owsFailDebugUnlessNetworkFailure(error)
// TODO: Present this error to the user.
viewMode = .error
}
}
// MARK: - GifPickerLayoutDelegate
func imageInfosForLayout() -> [GiphyImageInfo] {
return imageInfos
}
// MARK: - Event Handlers
@objc
private func retryTapped(sender: UIGestureRecognizer) {
guard sender.state == .recognized else {
return
}
guard viewMode == .error else {
return
}
taskQueue.enqueueCancellingPrevious(operation: { @MainActor in
await self.tryToSearch(afterDelay: 0)
})
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
layout.invalidateLayout()
}
}