// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit import QuartzCore import GRDB import DifferenceKit import SessionUIKit import SignalUtilitiesKit import SignalCoreKit public class DocumentTileViewController: UIViewController, UITableViewDelegate, UITableViewDataSource { /// This should be larger than one screen size so we don't have to call it multiple times in rapid succession, but not /// so large that loading get's really chopping static let itemPageSize: Int = Int(11 * itemsPerPortraitRow) static let itemsPerPortraitRow: CGFloat = 4 static let interItemSpacing: CGFloat = 2 static let footerBarHeight: CGFloat = 40 static let loadMoreHeaderHeight: CGFloat = 100 private let viewModel: MediaGalleryViewModel private var hasLoadedInitialData: Bool = false private var didFinishInitialLayout: Bool = false private var isAutoLoadingNextPage: Bool = false private var currentTargetOffset: CGPoint? public weak var delegate: DocumentTileViewControllerDelegate? // MARK: - Initialization init(viewModel: MediaGalleryViewModel) { self.viewModel = viewModel Storage.shared.addObserver(viewModel.pagedDataObserver) super.init(nibName: nil, bundle: nil) } required public init?(coder aDecoder: NSCoder) { notImplemented() } deinit { NotificationCenter.default.removeObserver(self) } // MARK: - UI override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { return .allButUpsideDown } lazy var tableView: UITableView = { let result: UITableView = UITableView() result.themeBackgroundColor = .newConversation_background result.separatorStyle = .none result.showsVerticalScrollIndicator = false result.register(view: DocumentCell.self) result.delegate = self result.dataSource = self // Feels a bit weird to have content smashed all the way to the bottom edge. result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 20, right: 0) if #available(iOS 15.0, *) { result.sectionHeaderTopPadding = 0 } return result }() // MARK: - Lifecycle override public func viewDidLoad() { super.viewDidLoad() // Add a custom back button if this is the only view controller if self.navigationController?.viewControllers.first == self { let backButton = UIViewController.createOWSBackButton(target: self, selector: #selector(didPressDismissButton)) self.navigationItem.leftBarButtonItem = backButton } ViewControllerUtilities.setUpDefaultSessionStyle( for: self, title: MediaStrings.document, hasCustomBackButton: false ) view.addSubview(self.tableView) tableView.autoPin(toEdgesOf: view) // Notifications NotificationCenter.default.addObserver( self, selector: #selector(applicationDidBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(applicationDidResignActive(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil ) } public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) startObservingChanges() } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.didFinishInitialLayout = true } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) stopObservingChanges() } @objc func applicationDidBecomeActive(_ notification: Notification) { /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query DispatchQueue.main.async { [weak self] in self?.startObservingChanges() } } @objc func applicationDidResignActive(_ notification: Notification) { stopObservingChanges() } // MARK: - Updating private func performInitialScrollIfNeeded() { // Ensure this hasn't run before and that we have data (The 'galleryData' will always // contain something as the 'empty' state is a section within 'galleryData') guard !self.didFinishInitialLayout && self.hasLoadedInitialData else { return } // If we have a focused item then we want to scroll to it guard let focusedIndexPath: IndexPath = self.viewModel.focusedIndexPath else { return } Logger.debug("scrolling to focused item at indexPath: \(focusedIndexPath)") self.view.layoutIfNeeded() self.tableView.scrollToRow(at: focusedIndexPath, at: .middle, animated: false) // Now that the data has loaded we need to check if either of the "load more" sections are // visible and trigger them if so // // Note: We do it this way as we want to trigger the load behaviour for the first section // if it has one before trying to trigger the load behaviour for the last section self.autoLoadNextPageIfNeeded() } private func autoLoadNextPageIfNeeded() { guard self.hasLoadedInitialData && !self.isAutoLoadingNextPage else { return } self.isAutoLoadingNextPage = true DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in self?.isAutoLoadingNextPage = false // Note: We sort the headers as we want to prioritise loading newer pages over older ones let sortedVisibleIndexPaths: [IndexPath] = (self?.tableView.indexPathsForVisibleRows ?? []).sorted() for headerIndexPath in sortedVisibleIndexPaths { let section: MediaGalleryViewModel.SectionModel? = self?.viewModel.galleryData[safe: headerIndexPath.section] switch section?.model { case .loadNewer, .loadOlder: // Attachments are loaded in descending order so 'loadOlder' actually corresponds with // 'pageAfter' in this case DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ? .pageAfter : .pageBefore ) } return default: continue } } } } private func startObservingChanges() { // Start observing for data changes (will callback on the main thread) self.viewModel.onGalleryChange = { [weak self] updatedGalleryData, changeset in self?.handleUpdates(updatedGalleryData, changeset: changeset) } } private func stopObservingChanges() { // Note: The 'pagedDataObserver' will continue to get changes but // we don't want to trigger any UI updates self.viewModel.onGalleryChange = nil } private func handleUpdates( _ updatedGalleryData: [MediaGalleryViewModel.SectionModel], changeset: StagedChangeset<[MediaGalleryViewModel.SectionModel]> ) { // Ensure the first load runs without animations (if we don't do this the cells will animate // in from a frame of CGRect.zero) guard hasLoadedInitialData else { self.viewModel.updateGalleryData(updatedGalleryData) UIView.performWithoutAnimation { self.tableView.reloadData() self.hasLoadedInitialData = true self.performInitialScrollIfNeeded() } return } let isInsertingAtTop: Bool = { let oldFirstSectionIsLoadMore: Bool = ( self.viewModel.galleryData.first?.model == .loadNewer || self.viewModel.galleryData.first?.model == .loadOlder ) let oldTargetSectionIndex: Int = (oldFirstSectionIsLoadMore ? 1 : 0) guard let newTargetSectionIndex = updatedGalleryData .firstIndex(where: { $0.model == self.viewModel.galleryData[safe: oldTargetSectionIndex]?.model }), let oldFirstItem: MediaGalleryViewModel.Item = self.viewModel.galleryData[safe: oldTargetSectionIndex]?.elements.first, let newFirstItemIndex = updatedGalleryData[safe: newTargetSectionIndex]?.elements.firstIndex(of: oldFirstItem) else { return false } return (newTargetSectionIndex > oldTargetSectionIndex || newFirstItemIndex > 0) }() CATransaction.begin() if isInsertingAtTop { CATransaction.setDisableActions(true) } self.tableView.reload( using: changeset, with: .automatic, interrupt: { $0.changeCount > MediaTileViewController.itemPageSize } ) { [weak self] updatedData in self?.viewModel.updateGalleryData(updatedData) } CATransaction.setCompletionBlock { [weak self] in // If one of the "load more" sections is still visible once the animation completes then // trigger another "load more" (after a small delay to minimize animation bugginess) self?.autoLoadNextPageIfNeeded() } CATransaction.commit() } // MARK: - Interactions @objc public func didPressDismissButton() { let presentedNavController: UINavigationController? = (self.presentingViewController as? UINavigationController) let mediaPageViewController: MediaPageViewController? = ( (presentedNavController?.viewControllers.last as? MediaPageViewController) ?? (self.presentingViewController as? MediaPageViewController) ) // If the album was presented from a 'MediaPageViewController' and it has no more data (ie. // all album items had been deleted) then dismiss to the screen before that one guard mediaPageViewController?.viewModel.albumData.isEmpty != true else { presentedNavController?.presentingViewController?.dismiss(animated: true, completion: nil) return } dismiss(animated: true, completion: nil) } // MARK: - UITableViewDataSource public func numberOfSections(in tableView: UITableView) -> Int { return self.viewModel.galleryData.count } public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return self.viewModel.galleryData[section].elements.count } public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell: DocumentCell = tableView.dequeue(type: DocumentCell.self, for: indexPath) cell.update(with: self.viewModel.galleryData[indexPath.section].elements[indexPath.row]) return cell } // MARK: - UITableViewDelegate public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[section] switch section.model { case .emptyGallery, .loadOlder, .loadNewer: let headerView: DocumentStaticHeaderView = DocumentStaticHeaderView() headerView.configure( title: { switch section.model { case .emptyGallery: return "DOCUMENT_TILES_EMPTY_DOCUMENT".localized() case .loadOlder: return "DOCUMENT_TILES_LOADING_OLDER_LABEL".localized() case .loadNewer: return "DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL".localized() case .galleryMonth: return "" // Impossible case } }() ) return headerView case .galleryMonth(let date): let headerView: DocumentSectionHeaderView = DocumentSectionHeaderView() headerView.configure(title: date.localizedString) return headerView } } public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[section] switch section.model { case .emptyGallery, .loadOlder, .loadNewer: return MediaTileViewController.loadMoreHeaderHeight case .galleryMonth: return 50 } } public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) let attachment: Attachment = self.viewModel.galleryData[indexPath.section].elements[indexPath.row].attachment guard let originalFilePath: String = attachment.originalFilePath else { return } let fileUrl: URL = URL(fileURLWithPath: originalFilePath) // Open a preview of the document for text, pdf or microsoft files if attachment.isText || attachment.isMicrosoftDoc || attachment.contentType == OWSMimeTypeApplicationPdf { delegate?.preview(fileUrl: fileUrl) return } // Otherwise share the file delegate?.share(fileUrl: fileUrl) } } // MARK: - View class DocumentCell: UITableViewCell { // MARK: - Initialization override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setUpViewHierarchy() setupLayout() } required init?(coder: NSCoder) { super.init(coder: coder) setUpViewHierarchy() setupLayout() } // MARK: - UI private static let iconImageViewSize: CGSize = CGSize(width: 31, height: 40) private let iconImageView: UIImageView = { let result: UIImageView = UIImageView(image: #imageLiteral(resourceName: "File").withRenderingMode(.alwaysTemplate)) result.translatesAutoresizingMaskIntoConstraints = false result.themeTintColor = .textPrimary result.contentMode = .scaleAspectFit return result }() private let titleLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false result.setContentHuggingPriority(.defaultHigh, for: .horizontal) result.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) result.font = .boldSystemFont(ofSize: Values.smallFontSize) result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail result.numberOfLines = 2 return result }() private let timeLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false result.setContentHuggingPriority(.defaultHigh, for: .horizontal) result.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) result.font = .systemFont(ofSize: Values.smallFontSize) result.themeTextColor = .textSecondary result.lineBreakMode = .byTruncatingTail return result }() private let detailLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false result.setContentHuggingPriority(.defaultHigh, for: .horizontal) result.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) result.font = .systemFont(ofSize: Values.smallFontSize) result.themeTextColor = .textSecondary result.lineBreakMode = .byTruncatingTail return result }() private func setUpViewHierarchy() { themeBackgroundColor = .clear backgroundView = UIView() backgroundView?.themeBackgroundColor = .settings_tabBackground backgroundView?.layer.cornerRadius = 5 selectedBackgroundView = UIView() selectedBackgroundView?.themeBackgroundColor = .highlighted(.settings_tabBackground) selectedBackgroundView?.layer.cornerRadius = 5 contentView.addSubview(iconImageView) contentView.addSubview(titleLabel) contentView.addSubview(timeLabel) contentView.addSubview(detailLabel) } // MARK: - Layout private func setupLayout() { NSLayoutConstraint.activate([ iconImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), iconImageView.topAnchor.constraint( greaterThanOrEqualTo: contentView.topAnchor, constant: (Values.verySmallSpacing + Values.verySmallSpacing) ), iconImageView.leftAnchor.constraint( equalTo: contentView.leftAnchor, constant: (Values.largeSpacing + Values.mediumSpacing) ), iconImageView.bottomAnchor.constraint( lessThanOrEqualTo: contentView.bottomAnchor, constant: -(Values.verySmallSpacing + Values.verySmallSpacing) ), titleLabel.topAnchor.constraint( equalTo: contentView.topAnchor, constant: (Values.verySmallSpacing + Values.verySmallSpacing) ), titleLabel.leftAnchor.constraint(equalTo: iconImageView.rightAnchor, constant: Values.mediumSpacing), titleLabel.rightAnchor.constraint( lessThanOrEqualTo: timeLabel.leftAnchor, constant: -Values.mediumSpacing ), timeLabel.topAnchor.constraint(equalTo: iconImageView.topAnchor), timeLabel.rightAnchor.constraint( equalTo: contentView.rightAnchor, constant: -(Values.mediumSpacing + Values.largeSpacing) ), detailLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: Values.smallSpacing), detailLabel.leftAnchor.constraint(equalTo: iconImageView.rightAnchor, constant: Values.mediumSpacing), detailLabel.rightAnchor.constraint( lessThanOrEqualTo: contentView.rightAnchor, constant: -(Values.verySmallSpacing + Values.largeSpacing) ), detailLabel.bottomAnchor.constraint( lessThanOrEqualTo: contentView.bottomAnchor, constant: -(Values.verySmallSpacing + Values.smallSpacing) ), ]) } override func layoutSubviews() { super.layoutSubviews() backgroundView?.frame = CGRect( x: Values.largeSpacing, y: Values.verySmallSpacing, width: (contentView.bounds.width - (Values.largeSpacing * 2)), height: (contentView.bounds.height - (Values.verySmallSpacing * 2)) ) selectedBackgroundView?.frame = (backgroundView?.frame ?? .zero) } // MARK: - Content func update(with item: MediaGalleryViewModel.Item) { let attachment = item.attachment titleLabel.text = (attachment.sourceFilename ?? "File") detailLabel.text = "\(Format.fileSize(attachment.byteCount)))" timeLabel.text = Date( timeIntervalSince1970: TimeInterval(item.interactionTimestampMs / 1000) ).formattedForDisplay } } class DocumentSectionHeaderView: UIView { // HACK: scrollbar incorrectly appears *behind* section headers // in collection view on iOS11 =( private class AlwaysOnTopLayer: CALayer { override var zPosition: CGFloat { get { return 0 } set {} } } let label: UILabel override class var layerClass: AnyClass { get { // HACK: scrollbar incorrectly appears *behind* section headers // in collection view on iOS11 =( return AlwaysOnTopLayer.self } } override init(frame: CGRect) { label = UILabel() label.themeTextColor = .textPrimary super.init(frame: frame) self.themeBackgroundColor = .clear let backgroundView: UIView = UIView() backgroundView.themeBackgroundColor = .newConversation_background addSubview(backgroundView) backgroundView.pin(to: self) self.addSubview(label) label.pin(.leading, to: .leading, of: self, withInset: Values.largeSpacing) label.pin(.trailing, to: .trailing, of: self, withInset: -Values.largeSpacing) label.center(.vertical, in: self) } @available(*, unavailable, message: "Unimplemented") required init?(coder aDecoder: NSCoder) { notImplemented() } public func configure(title: String) { self.label.text = title } } class DocumentStaticHeaderView: UIView { let label = UILabel() override init(frame: CGRect) { super.init(frame: frame) addSubview(label) label.themeTextColor = .textPrimary label.textAlignment = .center label.numberOfLines = 0 label.autoPinEdgesToSuperviewMargins(with: UIEdgeInsets(top: 0, leading: Values.largeSpacing, bottom: 0, trailing: Values.largeSpacing)) } @available(*, unavailable, message: "Unimplemented") required public init?(coder aDecoder: NSCoder) { notImplemented() } public func configure(title: String) { self.label.text = title } } // MARK: - DocumentTitleViewControllerDelegate public protocol DocumentTileViewControllerDelegate: AnyObject { func share(fileUrl: URL) func preview(fileUrl: URL) }