session-ios/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift
Morgan Pretty 6eeb0ec7ac Fixed most of the styling issues raised during QA
Copy tweak
Added a toast when copying the sessionId or group URL (fixes to the toast UI as well)
Fixed the new conversation screen styling
Fixed the styling of the various attachment screens
Updated the buttons on the attachment screen to behave like the input view buttons
Removed the old OWSNavigationBar and OWSNavigationController (logic was buggy and not actually needed in most cases)
2022-09-30 18:22:28 +10:00

772 lines
27 KiB
Swift

//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
import AVFoundation
import MediaPlayer
import CoreServices
import PromiseKit
import SessionUIKit
import SessionMessagingKit
import SignalCoreKit
public protocol AttachmentApprovalViewControllerDelegate: AnyObject {
func attachmentApproval(
_ attachmentApproval: AttachmentApprovalViewController,
didApproveAttachments attachments: [SignalAttachment],
forThreadId threadId: String,
messageText: String?
)
func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController)
func attachmentApproval(
_ attachmentApproval: AttachmentApprovalViewController,
didChangeMessageText newMessageText: String?
)
func attachmentApproval(
_ attachmentApproval: AttachmentApprovalViewController,
didRemoveAttachment attachment: SignalAttachment
)
func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController)
}
// MARK: -
@objc
public enum AttachmentApprovalViewControllerMode: UInt {
case modal
case sharedNavigation
}
// MARK: -
public class AttachmentApprovalViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
public override var preferredStatusBarStyle: UIStatusBarStyle {
return ThemeManager.currentTheme.statusBarStyle
}
public enum Mode: UInt {
case modal
case sharedNavigation
}
// MARK: - Properties
private let mode: Mode
private let threadId: String
private let isAddMoreVisible: Bool
public weak var approvalDelegate: AttachmentApprovalViewControllerDelegate?
public var isEditingCaptions = false {
didSet { updateContents() }
}
let attachmentItemCollection: AttachmentItemCollection
var attachmentItems: [SignalAttachmentItem] {
return attachmentItemCollection.attachmentItems
}
var attachments: [SignalAttachment] {
return attachmentItems.map { (attachmentItem) in
autoreleasepool {
return self.processedAttachment(forAttachmentItem: attachmentItem)
}
}
}
public var pageViewControllers: [AttachmentPrepViewController]? {
return viewControllers?.compactMap { $0 as? AttachmentPrepViewController }
}
public var currentPageViewController: AttachmentPrepViewController? {
return pageViewControllers?.first
}
var currentItem: SignalAttachmentItem? {
get { return currentPageViewController?.attachmentItem }
set { setCurrentItem(newValue, direction: .forward, animated: false) }
}
private var cachedPages: [SignalAttachmentItem: AttachmentPrepViewController] = [:]
public var shouldHideControls: Bool {
guard let pageViewController: AttachmentPrepViewController = pageViewControllers?.first else {
return false
}
return pageViewController.shouldHideControls
}
override public var inputAccessoryView: UIView? {
bottomToolView.layoutIfNeeded()
return bottomToolView
}
override public var canBecomeFirstResponder: Bool {
return !shouldHideControls
}
public var messageText: String? {
get { return bottomToolView.attachmentTextToolbar.messageText }
set { bottomToolView.attachmentTextToolbar.messageText = newValue }
}
// MARK: - Initializers
@available(*, unavailable, message:"use attachment: constructor instead.")
required public init?(coder aDecoder: NSCoder) {
notImplemented()
}
required public init(
mode: Mode,
threadId: String,
attachments: [SignalAttachment]
) {
assert(attachments.count > 0)
self.mode = mode
self.threadId = threadId
let attachmentItems = attachments.map { SignalAttachmentItem(attachment: $0 )}
self.isAddMoreVisible = (mode == .sharedNavigation)
self.attachmentItemCollection = AttachmentItemCollection(attachmentItems: attachmentItems, isAddMoreVisible: isAddMoreVisible)
super.init(
transitionStyle: .scroll,
navigationOrientation: .horizontal,
options: [
.interPageSpacing: kSpacingBetweenItems
]
)
self.dataSource = self
self.delegate = self
NotificationCenter.default.addObserver(
self,
selector: #selector(didBecomeActive),
name: .OWSApplicationDidBecomeActive,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
public class func wrappedInNavController(
threadId: String,
attachments: [SignalAttachment],
approvalDelegate: AttachmentApprovalViewControllerDelegate
) -> UINavigationController {
let vc = AttachmentApprovalViewController(mode: .modal, threadId: threadId, attachments: attachments)
vc.approvalDelegate = approvalDelegate
let navController = StyledNavigationController(rootViewController: vc)
return navController
}
// MARK: - UI
private let kSpacingBetweenItems: CGFloat = 20
private lazy var bottomToolView: AttachmentApprovalInputAccessoryView = {
let bottomToolView = AttachmentApprovalInputAccessoryView()
bottomToolView.delegate = self
bottomToolView.attachmentTextToolbar.attachmentTextToolbarDelegate = self
bottomToolView.galleryRailView.delegate = self
return bottomToolView
}()
private var galleryRailView: GalleryRailView { return bottomToolView.galleryRailView }
private lazy var touchInterceptorView: UIView = {
let view: UIView = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.isHidden = true
let tapGesture = UITapGestureRecognizer(
target: self,
action: #selector(didTapTouchInterceptorView(gesture:))
)
view.addGestureRecognizer(tapGesture)
return view
}()
private lazy var pagerScrollView: UIScrollView? = {
// This is kind of a hack. Since we don't have first class access to the superview's `scrollView`
// we traverse the view hierarchy until we find it.
let pagerScrollView = view.subviews.first { $0 is UIScrollView } as? UIScrollView
assert(pagerScrollView != nil)
return pagerScrollView
}()
// MARK: - Lifecycle
override public func viewDidLoad() {
super.viewDidLoad()
self.view.themeBackgroundColor = .newConversation_background
// Avoid an unpleasant "bounce" which doesn't make sense in the context of a single item.
pagerScrollView?.isScrollEnabled = (attachmentItems.count > 1)
guard let firstItem = attachmentItems.first else {
owsFailDebug("firstItem was unexpectedly nil")
return
}
self.setCurrentItem(firstItem, direction: .forward, animated: false)
view.addSubview(touchInterceptorView)
// layout immediately to avoid animating the layout process during the transition
UIView.performWithoutAnimation {
self.currentPageViewController?.view.layoutIfNeeded()
}
// If the first item is just text, or is a URL and LinkPreviews are disabled
// then just fill the 'message' box with it
if firstItem.attachment.isText || (firstItem.attachment.isUrl && LinkPreview.previewUrl(for: firstItem.attachment.text()) == nil) {
bottomToolView.attachmentTextToolbar.messageText = firstItem.attachment.text()
}
setupLayout()
}
override public func viewWillAppear(_ animated: Bool) {
Logger.debug("")
super.viewWillAppear(animated)
updateContents()
}
override public func viewDidAppear(_ animated: Bool) {
Logger.debug("")
super.viewDidAppear(animated)
updateContents()
}
// MARK: - Layout
private func setupLayout() {
touchInterceptorView.autoPinEdgesToSuperviewEdges()
}
// MARK: - Notifications
@objc func didBecomeActive() {
AssertIsOnMainThread()
updateContents()
}
// MARK: - Contents
private func updateContents() {
updateNavigationBar()
updateInputAccessory()
touchInterceptorView.isHidden = !isEditingCaptions
}
// MARK: - Input Accessory
public func updateInputAccessory() {
var currentPageViewController: AttachmentPrepViewController?
if pageViewControllers?.count == 1 {
currentPageViewController = pageViewControllers?.first
}
let currentAttachmentItem: SignalAttachmentItem? = currentPageViewController?.attachmentItem
let hasPresentedView = (self.presentedViewController != nil)
let isToolbarFirstResponder = bottomToolView.hasFirstResponder
if !shouldHideControls, !isFirstResponder, !hasPresentedView, !isToolbarFirstResponder {
becomeFirstResponder()
}
bottomToolView.update(
isEditingCaptions: isEditingCaptions,
currentAttachmentItem: currentAttachmentItem,
shouldHideControls: shouldHideControls
)
}
// MARK: - Navigation Bar
public func updateNavigationBar() {
guard !shouldHideControls else {
self.navigationItem.leftBarButtonItem = nil
self.navigationItem.rightBarButtonItem = nil
return
}
guard !isEditingCaptions else {
// Hide all navigation bar items while the caption view is open.
self.navigationItem.leftBarButtonItem = UIBarButtonItem(
//"Title for 'caption' mode of the attachment approval view."
title: "ATTACHMENT_APPROVAL_CAPTION_TITLE".localized(),
style: .plain,
target: nil,
action: nil
)
let doneButton = navigationBarButton(
imageName: "image_editor_checkmark_full",
selector: #selector(didTapCaptionDone(sender:))
)
let navigationBarItems = [doneButton]
updateNavigationBar(navigationBarItems: navigationBarItems)
return
}
var navigationBarItems = [UIView]()
if viewControllers?.count == 1, let firstViewController: AttachmentPrepViewController = viewControllers?.first as? AttachmentPrepViewController {
navigationBarItems = firstViewController.navigationBarItems()
// Show the caption UI if there's more than one attachment
// OR if the attachment already has a caption.
if attachmentItemCollection.count > 0, (firstViewController.attachmentItem.captionText?.count ?? 0) > 0 {
let captionButton = navigationBarButton(
imageName: "image_editor_caption",
selector: #selector(didTapCaption(sender:))
)
navigationBarItems.append(captionButton)
}
}
updateNavigationBar(navigationBarItems: navigationBarItems)
if mode != .sharedNavigation {
// Mimic a UIBarButtonItem of type .cancel, but with a shadow.
let cancelButton = OWSButton(title: CommonStrings.cancelButton) { [weak self] in
self?.cancelPressed()
}
cancelButton.titleLabel?.font = .systemFont(ofSize: 17.0)
cancelButton.setThemeTitleColor(.textPrimary, for: .normal)
cancelButton.setThemeTitleColor(.textSecondary, for: .highlighted)
cancelButton.sizeToFit()
navigationItem.leftBarButtonItem = UIBarButtonItem(customView: cancelButton)
}
else {
navigationItem.leftBarButtonItem = nil
}
}
// MARK: - View Helpers
func remove(attachmentItem: SignalAttachmentItem) {
if attachmentItem.isEqual(to: currentItem) {
if let nextItem = attachmentItemCollection.itemAfter(item: attachmentItem) {
setCurrentItem(nextItem, direction: .forward, animated: true)
}
else if let prevItem = attachmentItemCollection.itemBefore(item: attachmentItem) {
setCurrentItem(prevItem, direction: .reverse, animated: true)
}
else {
owsFailDebug("removing last item shouldn't be possible because rail should not be visible")
return
}
}
self.attachmentItemCollection.remove(item: attachmentItem)
self.approvalDelegate?.attachmentApproval(self, didRemoveAttachment: attachmentItem.attachment)
self.updateMediaRail()
}
// MARK: - UIPageViewControllerDelegate
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
Logger.debug("")
assert(pendingViewControllers.count == 1)
pendingViewControllers.forEach { viewController in
guard let pendingPage = viewController as? AttachmentPrepViewController else {
owsFailDebug("unexpected viewController: \(viewController)")
return
}
// use compact scale when keyboard is popped.
let scale: AttachmentPrepViewController.AttachmentViewScale = self.isFirstResponder ? .fullsize : .compact
pendingPage.setAttachmentViewScale(scale, animated: false)
}
}
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted: Bool) {
Logger.debug("")
assert(previousViewControllers.count == 1)
previousViewControllers.forEach { viewController in
guard let previousPage = viewController as? AttachmentPrepViewController else {
owsFailDebug("unexpected viewController: \(viewController)")
return
}
if transitionCompleted {
previousPage.zoomOut(animated: false)
updateMediaRail()
}
}
}
// MARK: - UIPageViewControllerDataSource
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let currentViewController = viewController as? AttachmentPrepViewController else {
owsFailDebug("unexpected viewController: \(viewController)")
return nil
}
let currentItem = currentViewController.attachmentItem
guard let previousItem = attachmentItem(before: currentItem) else { return nil }
guard let previousPage: AttachmentPrepViewController = buildPage(item: previousItem) else {
return nil
}
return previousPage
}
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
Logger.debug("")
guard let currentViewController = viewController as? AttachmentPrepViewController else {
owsFailDebug("unexpected viewController: \(viewController)")
return nil
}
let currentItem = currentViewController.attachmentItem
guard let nextItem = attachmentItem(after: currentItem) else { return nil }
guard let nextPage: AttachmentPrepViewController = buildPage(item: nextItem) else {
return nil
}
return nextPage
}
@objc
public override func setViewControllers(_ viewControllers: [UIViewController]?, direction: UIPageViewController.NavigationDirection, animated: Bool, completion: ((Bool) -> Void)? = nil) {
super.setViewControllers(
viewControllers,
direction: direction,
animated: animated
) { [weak self] finished in
completion?(finished)
self?.updateContents()
}
}
private func buildPage(item: SignalAttachmentItem) -> AttachmentPrepViewController? {
if let cachedPage = cachedPages[item] {
Logger.debug("cache hit.")
return cachedPage
}
Logger.debug("cache miss.")
let viewController = AttachmentPrepViewController(attachmentItem: item)
viewController.prepDelegate = self
cachedPages[item] = viewController
return viewController
}
private func setCurrentItem(_ item: SignalAttachmentItem?, direction: UIPageViewController.NavigationDirection, animated isAnimated: Bool) {
guard let item: SignalAttachmentItem = item, let page = self.buildPage(item: item) else {
Logger.error("unexpectedly unable to build new page")
return
}
page.loadViewIfNeeded()
self.setViewControllers([page], direction: direction, animated: isAnimated, completion: nil)
updateMediaRail()
}
func updateMediaRail() {
guard let currentItem = self.currentItem else {
Logger.error("currentItem was unexpectedly nil")
return
}
let cellViewBuilder: (GalleryRailItem) -> GalleryRailCellView = { [weak self] railItem in
switch railItem {
case is AddMoreRailItem:
return GalleryRailCellView()
case is SignalAttachmentItem:
let cell = ApprovalRailCellView()
cell.approvalRailCellDelegate = self
return cell
default:
Logger.error("unexpted rail item type: \(railItem)")
return GalleryRailCellView()
}
}
galleryRailView.configureCellViews(
album: (attachmentItemCollection.attachmentItems as [GalleryRailItem])
.appending(attachmentItemCollection.isAddMoreVisible ?
AddMoreRailItem() :
nil
),
focusedItem: currentItem,
cellViewBuilder: cellViewBuilder
)
if isAddMoreVisible {
galleryRailView.isHidden = false
}
else if attachmentItemCollection.attachmentItems.count > 1 {
galleryRailView.isHidden = false
}
else {
galleryRailView.isHidden = true
}
}
// For any attachments edited with the image editor, returns a
// new SignalAttachment that reflects those changes. Otherwise,
// returns the original attachment.
//
// If any errors occurs in the export process, we fail over to
// sending the original attachment. This seems better than trying
// to involve the user in resolving the issue.
func processedAttachment(forAttachmentItem attachmentItem: SignalAttachmentItem) -> SignalAttachment {
guard let imageEditorModel = attachmentItem.imageEditorModel else {
// Image was not edited.
return attachmentItem.attachment
}
guard imageEditorModel.isDirty() else {
// Image editor has no changes.
return attachmentItem.attachment
}
guard let dstImage = ImageEditorCanvasView.renderForOutput(model: imageEditorModel, transform: imageEditorModel.currentTransform()) else {
owsFailDebug("Could not render for output.")
return attachmentItem.attachment
}
var dataUTI = kUTTypeImage as String
let maybeDstData: Data? = {
let isLossy: Bool = (
attachmentItem.attachment.mimeType.caseInsensitiveCompare(OWSMimeTypeImageJpeg) == .orderedSame
)
if isLossy {
dataUTI = kUTTypeJPEG as String
return dstImage.jpegData(compressionQuality: 0.9)
}
else {
dataUTI = kUTTypePNG as String
return dstImage.pngData()
}
}()
guard let dstData: Data = maybeDstData else {
owsFailDebug("Could not export for output.")
return attachmentItem.attachment
}
guard let dataSource = DataSourceValue.dataSource(with: dstData, utiType: dataUTI) else {
owsFailDebug("Could not prepare data source for output.")
return attachmentItem.attachment
}
// Rewrite the filename's extension to reflect the output file format.
var filename: String? = attachmentItem.attachment.sourceFilename
if let sourceFilename = attachmentItem.attachment.sourceFilename {
if let fileExtension: String = MIMETypeUtil.fileExtension(forUTIType: dataUTI) {
filename = (sourceFilename as NSString).deletingPathExtension.appendingFileExtension(fileExtension)
}
}
dataSource.sourceFilename = filename
let dstAttachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .medium)
if let attachmentError = dstAttachment.error {
owsFailDebug("Could not prepare attachment for output: \(attachmentError).")
return attachmentItem.attachment
}
// Preserve caption text.
dstAttachment.captionText = attachmentItem.captionText
return dstAttachment
}
func attachmentItem(before currentItem: SignalAttachmentItem) -> SignalAttachmentItem? {
guard let currentIndex = attachmentItems.firstIndex(of: currentItem) else {
owsFailDebug("currentIndex was unexpectedly nil")
return nil
}
let index: Int = attachmentItems.index(before: currentIndex)
guard let previousItem = attachmentItems[safe: index] else {
// already at first item
return nil
}
return previousItem
}
func attachmentItem(after currentItem: SignalAttachmentItem) -> SignalAttachmentItem? {
guard let currentIndex = attachmentItems.firstIndex(of: currentItem) else {
owsFailDebug("currentIndex was unexpectedly nil")
return nil
}
let index: Int = attachmentItems.index(after: currentIndex)
guard let nextItem = attachmentItems[safe: index] else {
// already at last item
return nil
}
return nextItem
}
// MARK: - Event Handlers
@objc
func didTapTouchInterceptorView(gesture: UITapGestureRecognizer) {
Logger.info("")
isEditingCaptions = false
}
private func cancelPressed() {
self.approvalDelegate?.attachmentApprovalDidCancel(self)
}
@objc func didTapCaption(sender: UIButton) {
Logger.verbose("")
isEditingCaptions = true
}
@objc func didTapCaptionDone(sender: UIButton) {
Logger.verbose("")
isEditingCaptions = false
}
}
// MARK: -
extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate {
func attachmentTextToolbarDidBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar) {}
func attachmentTextToolbarDidEndEditing(_ attachmentTextToolbar: AttachmentTextToolbar) {}
func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar) {
// Toolbar flickers in and out if there are errors
// and remains visible momentarily after share extension is dismissed.
// It's easiest to just hide it at this point since we're done with it.
currentPageViewController?.shouldAllowAttachmentViewResizing = false
attachmentTextToolbar.isUserInteractionEnabled = false
attachmentTextToolbar.isHidden = true
approvalDelegate?.attachmentApproval(self, didApproveAttachments: attachments, forThreadId: threadId, messageText: attachmentTextToolbar.messageText)
}
func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar) {
approvalDelegate?.attachmentApproval(self, didChangeMessageText: attachmentTextToolbar.messageText)
}
}
// MARK: -
extension AttachmentApprovalViewController: AttachmentPrepViewControllerDelegate {
func prepViewControllerUpdateNavigationBar() {
updateNavigationBar()
}
func prepViewControllerUpdateControls() {
updateInputAccessory()
}
}
// MARK: GalleryRail
extension SignalAttachmentItem: GalleryRailItem {
func buildRailItemView() -> UIView {
let imageView = UIImageView()
imageView.image = getThumbnailImage()
imageView.themeBackgroundColor = .backgroundSecondary
imageView.contentMode = .scaleAspectFill
return imageView
}
func isEqual(to other: GalleryRailItem?) -> Bool {
guard let otherAttachmentItem: SignalAttachmentItem = other as? SignalAttachmentItem else { return false }
return (self.attachment == otherAttachmentItem.attachment)
}
}
// MARK: -
extension AttachmentApprovalViewController: GalleryRailViewDelegate {
public func galleryRailView(_ galleryRailView: GalleryRailView, didTapItem imageRailItem: GalleryRailItem) {
if imageRailItem is AddMoreRailItem {
self.approvalDelegate?.attachmentApprovalDidTapAddMore(self)
return
}
guard let targetItem = imageRailItem as? SignalAttachmentItem else {
owsFailDebug("unexpected imageRailItem: \(imageRailItem)")
return
}
guard let currentItem: SignalAttachmentItem = currentItem, let currentIndex = attachmentItems.firstIndex(of: currentItem) else {
owsFailDebug("currentIndex was unexpectedly nil")
return
}
guard let targetIndex = attachmentItems.firstIndex(of: targetItem) else {
owsFailDebug("targetIndex was unexpectedly nil")
return
}
let direction: UIPageViewController.NavigationDirection = (currentIndex < targetIndex ? .forward : .reverse)
self.setCurrentItem(targetItem, direction: direction, animated: true)
}
}
// MARK: -
extension AttachmentApprovalViewController: ApprovalRailCellViewDelegate {
func approvalRailCellView(_ approvalRailCellView: ApprovalRailCellView, didRemoveItem attachmentItem: SignalAttachmentItem) {
remove(attachmentItem: attachmentItem)
}
func canRemoveApprovalRailCellView(_ approvalRailCellView: ApprovalRailCellView) -> Bool {
return self.attachmentItems.count > 1
}
}
// MARK: -
extension AttachmentApprovalViewController: AttachmentApprovalInputAccessoryViewDelegate {
public func attachmentApprovalInputUpdateMediaRail() {
updateMediaRail()
}
public func attachmentApprovalInputStartEditingCaptions() {
isEditingCaptions = true
}
public func attachmentApprovalInputStopEditingCaptions() {
isEditingCaptions = false
}
}