// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit import Combine import Photos import SignalUtilitiesKit import SignalCoreKit import SessionUIKit class SendMediaNavigationController: UINavigationController { public override var preferredStatusBarStyle: UIStatusBarStyle { return ThemeManager.currentTheme.statusBarStyle } // This is a sensitive constant, if you change it make sure to check // on iPhone5, 6, 6+, X, layouts. static let bottomButtonsCenterOffset: CGFloat = -50 private let threadId: String private var disposables: Set = Set() // MARK: - Initialization init(threadId: String) { self.threadId = threadId super.init(nibName: nil, bundle: nil) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Overrides override func viewDidLoad() { super.viewDidLoad() self.delegate = self let bottomButtonsCenterOffset = SendMediaNavigationController.bottomButtonsCenterOffset view.addSubview(batchModeButton) batchModeButton.setCompressionResistanceHigh() batchModeButton.centerYAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: bottomButtonsCenterOffset).isActive = true batchModeButton.centerXAnchor .constraint(equalTo: view.layoutMarginsGuide.trailingAnchor, constant: -20) .isActive = true view.addSubview(doneButton) doneButton.setCompressionResistanceHigh() doneButton.centerYAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: bottomButtonsCenterOffset).isActive = true doneButton.autoPinEdge(toSuperviewMargin: .trailing) view.addSubview(cameraModeButton) cameraModeButton.setCompressionResistanceHigh() cameraModeButton.centerYAnchor .constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: bottomButtonsCenterOffset) .isActive = true cameraModeButton.centerXAnchor .constraint(equalTo: view.layoutMarginsGuide.leadingAnchor, constant: 20) .isActive = true view.addSubview(mediaLibraryModeButton) mediaLibraryModeButton.setCompressionResistanceHigh() mediaLibraryModeButton.centerYAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: bottomButtonsCenterOffset).isActive = true mediaLibraryModeButton.centerXAnchor .constraint(equalTo: view.layoutMarginsGuide.leadingAnchor, constant: 20) .isActive = true } // MARK: - public weak var sendMediaNavDelegate: SendMediaNavDelegate? @objc public class func showingCameraFirst(threadId: String) -> SendMediaNavigationController { let navController = SendMediaNavigationController(threadId: threadId) navController.viewControllers = [navController.captureViewController] return navController } @objc public class func showingMediaLibraryFirst(threadId: String) -> SendMediaNavigationController { let navController = SendMediaNavigationController(threadId: threadId) navController.viewControllers = [navController.mediaLibraryViewController] return navController } var isInBatchSelectMode = false { didSet { if oldValue != isInBatchSelectMode { mediaLibraryViewController.batchSelectModeDidChange() guard let topViewController = viewControllers.last else { return } updateButtons(topViewController: topViewController) } } } func updateButtons(topViewController: UIViewController) { switch topViewController { case is AttachmentApprovalViewController: batchModeButton.isHidden = true doneButton.isHidden = true cameraModeButton.isHidden = true mediaLibraryModeButton.isHidden = true case is ImagePickerGridController: batchModeButton.isHidden = isInBatchSelectMode doneButton.isHidden = !isInBatchSelectMode || (attachmentDraftCollection.count == 0 && mediaLibrarySelections.count == 0) cameraModeButton.isHidden = false mediaLibraryModeButton.isHidden = true case is PhotoCaptureViewController: batchModeButton.isHidden = isInBatchSelectMode doneButton.isHidden = !isInBatchSelectMode || (attachmentDraftCollection.count == 0 && mediaLibrarySelections.count == 0) cameraModeButton.isHidden = true mediaLibraryModeButton.isHidden = false default: owsFailDebug("unexpected topViewController: \(topViewController)") } doneButton.updateCount() } func fadeTo(viewControllers: [UIViewController]) { let transition: CATransition = CATransition() transition.duration = 0.1 transition.type = CATransitionType.fade view.layer.add(transition, forKey: nil) setViewControllers(viewControllers, animated: false) } // MARK: - Events private func didTapBatchModeButton() { // There's no way to _disable_ batch mode. isInBatchSelectMode = true } private func didTapCameraModeButton() { Permissions.requestCameraPermissionIfNeeded { [weak self] in DispatchQueue.main.async { self?.fadeTo(viewControllers: ((self?.captureViewController).map { [$0] } ?? [])) } } } private func didTapMediaLibraryModeButton() { Permissions.requestLibraryPermissionIfNeeded { [weak self] in DispatchQueue.main.async { self?.fadeTo(viewControllers: ((self?.mediaLibraryViewController).map { [$0] } ?? [])) } } } // MARK: Views public static let bottomButtonWidth: CGFloat = 44 private lazy var doneButton: DoneButton = { let button = DoneButton() button.delegate = self return button }() private lazy var batchModeButton: InputViewButton = { let result: InputViewButton = InputViewButton( icon: UIImage(named: "media_send_batch_mode_disabled")? .withRenderingMode(.alwaysTemplate) ) { [weak self] in self?.didTapBatchModeButton() } return result }() private lazy var cameraModeButton: InputViewButton = { let result: InputViewButton = InputViewButton( icon: UIImage(named: "settings-avatar-camera-2")? .withRenderingMode(.alwaysTemplate) ) { [weak self] in self?.didTapCameraModeButton() } return result }() private lazy var mediaLibraryModeButton: InputViewButton = { let result: InputViewButton = InputViewButton( icon: UIImage(named: "actionsheet_camera_roll_black")? .withRenderingMode(.alwaysTemplate) ) { [weak self] in self?.didTapMediaLibraryModeButton() } return result }() // MARK: State private lazy var attachmentDraftCollection = AttachmentDraftCollection.empty // Lazy to avoid https://bugs.swift.org/browse/SR-6657 private var attachments: [SignalAttachment] { return attachmentDraftCollection.attachmentDrafts.map { $0.attachment } } private lazy var mediaLibrarySelections = OrderedDictionary() // Lazy to avoid https://bugs.swift.org/browse/SR-6657 // MARK: Child VC's private lazy var captureViewController: PhotoCaptureViewController = { let vc = PhotoCaptureViewController() vc.delegate = self return vc }() private lazy var mediaLibraryViewController: ImagePickerGridController = { let vc = ImagePickerGridController() vc.delegate = self vc.collectionView.accessibilityLabel = "Images" return vc }() private func pushApprovalViewController() { guard let sendMediaNavDelegate = self.sendMediaNavDelegate else { owsFailDebug("sendMediaNavDelegate was unexpectedly nil") return } let approvalViewController = AttachmentApprovalViewController( mode: .sharedNavigation, threadId: self.threadId, attachments: self.attachments ) approvalViewController.approvalDelegate = self approvalViewController.messageText = sendMediaNavDelegate.sendMediaNavInitialMessageText(self) pushViewController(approvalViewController, animated: true) } private func didRequestExit() { guard attachmentDraftCollection.count > 0 else { self.sendMediaNavDelegate?.sendMediaNavDidCancel(self) return } let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "SEND_MEDIA_ABANDON_TITLE".localized(), confirmTitle: "SEND_MEDIA_CONFIRM_ABANDON_ALBUM".localized(), confirmStyle: .danger, cancelStyle: .alert_text, onConfirm: { [weak self] _ in self?.sendMediaNavDelegate?.sendMediaNavDidCancel(self) } ) ) self.present(modal, animated: true) } } extension SendMediaNavigationController: UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { switch viewController { case is PhotoCaptureViewController: if attachmentDraftCollection.count == 1 && !isInBatchSelectMode { // User is navigating "back" to the previous view, indicating // they want to discard the previously captured item discardDraft() } case is ImagePickerGridController: if attachmentDraftCollection.count == 1 && !isInBatchSelectMode { isInBatchSelectMode = true self.mediaLibraryViewController.batchSelectModeDidChange() } default: break } self.updateButtons(topViewController: viewController) } // In case back navigation was canceled, we re-apply whatever is showing. func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { self.updateButtons(topViewController: viewController) } } extension SendMediaNavigationController: PhotoCaptureViewControllerDelegate { func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment) { attachmentDraftCollection.append(.camera(attachment: attachment)) if isInBatchSelectMode { updateButtons(topViewController: photoCaptureViewController) } else { pushApprovalViewController() } } func photoCaptureViewControllerDidCancel(_ photoCaptureViewController: PhotoCaptureViewController) { didRequestExit() } func discardDraft() { assert(attachmentDraftCollection.attachmentDrafts.count <= 1) if let lastAttachmentDraft = attachmentDraftCollection.attachmentDrafts.last { attachmentDraftCollection.remove(attachment: lastAttachmentDraft.attachment) } assert(attachmentDraftCollection.attachmentDrafts.count == 0) } } extension SendMediaNavigationController: ImagePickerGridControllerDelegate { func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController) { showApprovalAfterProcessingAnyMediaLibrarySelections() } func imagePickerDidCancel(_ imagePicker: ImagePickerGridController) { didRequestExit() } func showApprovalAfterProcessingAnyMediaLibrarySelections() { let mediaLibrarySelections: [MediaLibrarySelection] = self.mediaLibrarySelections.orderedValues let backgroundBlock: (ModalActivityIndicatorViewController) -> Void = { [weak self] modal in guard let strongSelf = self else { return } Publishers .MergeMany(mediaLibrarySelections.map { $0.publisher }) .collect() .sink( receiveCompletion: { result in switch result { case .finished: break case .failure(let error): Logger.error("failed to prepare attachments. error: \(error)") modal.dismiss { [weak self] in let modal: ConfirmationModal = ConfirmationModal( targetView: self?.view, info: ConfirmationModal.Info( title: "IMAGE_PICKER_FAILED_TO_PROCESS_ATTACHMENTS".localized(), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) ) self?.present(modal, animated: true) } } }, receiveValue: { attachments in Logger.debug("built all attachments") modal.dismiss { self?.attachmentDraftCollection.selectedFromPicker(attachments: attachments) self?.pushApprovalViewController() } } ) .store(in: &strongSelf.disposables) } ModalActivityIndicatorViewController.present( fromViewController: self, canCancel: false, onAppear: backgroundBlock ) } func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool { return mediaLibrarySelections.hasValue(forKey: asset) } func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPublisher: AnyPublisher) { guard !mediaLibrarySelections.hasValue(forKey: asset) else { return } let libraryMedia = MediaLibrarySelection( asset: asset, signalAttachmentPublisher: attachmentPublisher ) mediaLibrarySelections.append(key: asset, value: libraryMedia) updateButtons(topViewController: imagePicker) } func imagePicker(_ imagePicker: ImagePickerGridController, didDeselectAsset asset: PHAsset) { guard mediaLibrarySelections.hasValue(forKey: asset) else { return } mediaLibrarySelections.remove(key: asset) updateButtons(topViewController: imagePicker) } func imagePickerCanSelectAdditionalItems(_ imagePicker: ImagePickerGridController) -> Bool { return attachmentDraftCollection.count <= SignalAttachment.maxAttachmentsAllowed } } extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegate { func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) { sendMediaNavDelegate?.sendMediaNav(self, didChangeMessageText: newMessageText) } func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) { guard let removedDraft = attachmentDraftCollection.attachmentDrafts.first(where: { $0.attachment == attachment}) else { owsFailDebug("removedDraft was unexpectedly nil") return } switch removedDraft.source { case .picker(attachment: let pickerAttachment): mediaLibrarySelections.remove(key: pickerAttachment.asset) case .camera(attachment: _): break } attachmentDraftCollection.remove(attachment: attachment) } func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { sendMediaNavDelegate?.sendMediaNav(self, didApproveAttachments: attachments, forThreadId: threadId, messageText: messageText) } func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) { sendMediaNavDelegate?.sendMediaNavDidCancel(self) } func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) { // Current design dicates we'll go "back" to the single thing before us. assert(viewControllers.count == 2) // regardless of which VC we're going "back" to, we're in "batch" mode at this point. isInBatchSelectMode = true mediaLibraryViewController.batchSelectModeDidChange() popViewController(animated: true) } } private enum AttachmentDraft { case camera(attachment: SignalAttachment) case picker(attachment: MediaLibraryAttachment) } private extension AttachmentDraft { var attachment: SignalAttachment { switch self { case .camera(let cameraAttachment): return cameraAttachment case .picker(let pickerAttachment): return pickerAttachment.signalAttachment } } var source: AttachmentDraft { return self } } private final class AttachmentDraftCollection { lazy var attachmentDrafts = [AttachmentDraft]() // Lazy to avoid https://bugs.swift.org/browse/SR-6657 static var empty: AttachmentDraftCollection { return AttachmentDraftCollection(attachmentDrafts: []) } init(attachmentDrafts: [AttachmentDraft]) { self.attachmentDrafts = attachmentDrafts } // MARK: - var count: Int { return attachmentDrafts.count } var pickerAttachments: [MediaLibraryAttachment] { return attachmentDrafts.compactMap { attachmentDraft in switch attachmentDraft.source { case .picker(let pickerAttachment): return pickerAttachment case .camera: return nil } } } var cameraAttachments: [SignalAttachment] { return attachmentDrafts.compactMap { attachmentDraft in switch attachmentDraft.source { case .picker: return nil case .camera(let cameraAttachment): return cameraAttachment } } } func append(_ element: AttachmentDraft) { attachmentDrafts.append(element) } func remove(attachment: SignalAttachment) { attachmentDrafts.removeAll { $0.attachment == attachment } } func selectedFromPicker(attachments: [MediaLibraryAttachment]) { let pickedAttachments: Set = Set(attachments) let oldPickerAttachments: Set = Set(self.pickerAttachments) for removedAttachment in oldPickerAttachments.subtracting(pickedAttachments) { remove(attachment: removedAttachment.signalAttachment) } // enumerate over new attachments to maintain order from picker for attachment in attachments { guard !oldPickerAttachments.contains(attachment) else { continue } append(.picker(attachment: attachment)) } } } private struct MediaLibrarySelection: Hashable, Equatable { let asset: PHAsset let signalAttachmentPublisher: AnyPublisher var hashValue: Int { return asset.hashValue } var publisher: AnyPublisher { let asset = self.asset return signalAttachmentPublisher .map { MediaLibraryAttachment(asset: asset, signalAttachment: $0) } .eraseToAnyPublisher() } static func ==(lhs: MediaLibrarySelection, rhs: MediaLibrarySelection) -> Bool { return lhs.asset == rhs.asset } } private struct MediaLibraryAttachment: Hashable, Equatable { let asset: PHAsset let signalAttachment: SignalAttachment public var hashValue: Int { return asset.hashValue } public static func == (lhs: MediaLibraryAttachment, rhs: MediaLibraryAttachment) -> Bool { return lhs.asset == rhs.asset } } extension SendMediaNavigationController: DoneButtonDelegate { var doneButtonCount: Int { return attachmentDraftCollection.count - attachmentDraftCollection.pickerAttachments.count + mediaLibrarySelections.count } fileprivate func doneButtonWasTapped(_ doneButton: DoneButton) { assert(attachmentDraftCollection.count > 0 || mediaLibrarySelections.count > 0) showApprovalAfterProcessingAnyMediaLibrarySelections() } } private protocol DoneButtonDelegate: AnyObject { func doneButtonWasTapped(_ doneButton: DoneButton) var doneButtonCount: Int { get } } private class DoneButton: UIView { weak var delegate: DoneButtonDelegate? let numberFormatter: NumberFormatter = NumberFormatter() private var didTouchDownInside: Bool = false // MARK: - UI private let container: UIView = { let result: UIView = UIView() result.themeBackgroundColor = .inputButton_background result.layer.cornerRadius = 20 return result }() private lazy var badge: CircleView = { let result: CircleView = CircleView() result.themeBackgroundColor = .primary return result }() private lazy var badgeLabel: UILabel = { let result: UILabel = UILabel() result.font = UIFont.monospacedDigitSystemFont( ofSize: UIFont.preferredFont(forTextStyle: .subheadline).pointSize, weight: .regular ) result.themeTextColor = .black // Will render on the primary color so should always be black result.textAlignment = .center return result }() private lazy var chevron: UIView = { let image: UIImage = { guard CurrentAppContext().isRTL else { return #imageLiteral(resourceName: "small_chevron_right") } return #imageLiteral(resourceName: "small_chevron_left") }() let result: UIImageView = UIImageView(image: image.withRenderingMode(.alwaysTemplate)) result.contentMode = .scaleAspectFit result.themeTintColor = .textPrimary result.set(.width, to: 10) result.set(.height, to: 18) return result }() // MARK: - Lifecycle init() { super.init(frame: .zero) let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap(tapGesture:))) addGestureRecognizer(tapGesture) addSubview(container) container.pin(to: self) badge.addSubview(badgeLabel) badgeLabel.pin(to: badge, withInset: 4) // Constrain to be a pill that is at least a circle, and maybe wider. badgeLabel.autoPin(toAspectRatio: 1.0, relation: .greaterThanOrEqual) NSLayoutConstraint.autoSetPriority(.defaultLow) { badgeLabel.autoPinToSquareAspectRatio() } let stackView = UIStackView(arrangedSubviews: [badge, chevron]) stackView.axis = .horizontal stackView.alignment = .center stackView.spacing = 9 addSubview(stackView) stackView.pin(.top, to: .top, of: self, withInset: 7) stackView.pin(.leading, to: .leading, of: self, withInset: 8) stackView.pin(.trailing, to: .trailing, of: self, withInset: -8) stackView.pin(.bottom, to: .bottom, of: self, withInset: -7) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Functions func updateCount() { guard let delegate = delegate else { return } badgeLabel.text = numberFormatter.string(for: delegate.doneButtonCount) } // MARK: - Interaction private func animate( to scale: CGFloat, themeBackgroundColor: ThemeValue, themeBadgeBackgroundColor: ThemeValue, themeTintColor: ThemeValue ) { UIView.animate(withDuration: 0.25) { self.container.transform = CGAffineTransform.identity.scale(scale) self.badgeLabel.themeTextColor = themeTintColor self.badge.themeBackgroundColor = themeBadgeBackgroundColor self.container.themeBackgroundColor = themeBackgroundColor } } private func expand() { animate( to: (InputViewButton.expandedSize / InputViewButton.size), themeBackgroundColor: .primary, themeBadgeBackgroundColor: .inputButton_background, themeTintColor: .textPrimary ) } private func collapse() { animate( to: 1, themeBackgroundColor: .inputButton_background, themeBadgeBackgroundColor: .primary, themeTintColor: .black ) } @objc func didTap(tapGesture: UITapGestureRecognizer) { delegate?.doneButtonWasTapped(self) } override func touchesBegan(_ touches: Set, with event: UIEvent?) { guard isUserInteractionEnabled, let location: CGPoint = touches.first?.location(in: self), bounds.contains(location) else { return } didTouchDownInside = true expand() } override func touchesMoved(_ touches: Set, with event: UIEvent?) { guard isUserInteractionEnabled, let location: CGPoint = touches.first?.location(in: self), bounds.contains(location), didTouchDownInside else { if didTouchDownInside { collapse() } return } expand() } override func touchesEnded(_ touches: Set, with event: UIEvent?) { if didTouchDownInside { collapse() } didTouchDownInside = false } override func touchesCancelled(_ touches: Set, with event: UIEvent?) { if didTouchDownInside { collapse() } didTouchDownInside = false } } // MARK: - SendMediaNavDelegate protocol SendMediaNavDelegate: AnyObject { func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController?) func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) }